From bb7a652b753ef7495352f8d08dcdec41c95c715c Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sat, 9 May 2026 22:42:50 +0800 Subject: [PATCH 1/7] refactor(wiki): rename user-facing "Wiki" to "Knowledge" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module is an LLM-curated knowledge base built around automated ingest, derivation, and synthesis — not a hand-authored wiki. User-facing copy is updated to reflect that, and this positioning informs follow-up decisions such as not adding page-edit/CRUD endpoints. Changes: - packages/web/src/locales/main/{en,zh,ja}.ts: rename nav.wiki, wiki.title, wiki.subtitle, and related labels; reword copy that implied hand-editing (e.g. "compile into the wiki" → "ingest as a knowledge source"); drop stale references to PowerMem from user-facing strings where the backing engine is already surfaced by the stats panel. - packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx: update assertions to match the new strings. - README.md / README_CN.md / README_JP.md: v0.4.0 roadmap entry renamed from "LLM Wiki" to "LLM Knowledge module" in all three languages. Code identifiers, routes (/wiki, /api/wiki/*), file paths, and TypeScript types remain unchanged — renaming them is high-cost, zero-user-value churn that would bloat the diff without any observable improvement. --- README.md | 4 +- README_CN.md | 4 +- README_JP.md | 4 +- packages/web/src/locales/main/en.ts | 78 +++++++++--------- packages/web/src/locales/main/ja.ts | 80 +++++++++---------- packages/web/src/locales/main/zh.ts | 78 +++++++++--------- .../modules/wiki/__tests__/WikiPage.test.tsx | 24 +++--- 7 files changed, 136 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 97dee59..0c84ed1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Memory is the backbone of the **Save** capability. We build on [**PowerMem**](ht - First end-to-end memory-backed skill: a daily package download digest with period-over-period deltas. - Memory-adjacent observability — per-session spend, scheduled cost digests, and models.dev pricing. -**Next (v0.4.0)**: full seekdb hybrid retrieval and a self-maintaining LLM Wiki module — persistent wiki pages that compound with every ingest, with Ebbinghaus decay and freshness-weighted ranking keeping content alive. See the [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) for tracked work. +**Next (v0.4.0)**: full seekdb hybrid retrieval and a self-maintaining LLM Knowledge module — persistent knowledge pages that compound with every ingest, with Ebbinghaus decay and freshness-weighted ranking keeping content alive. See the [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) for tracked work. ## Product Tour @@ -182,7 +182,7 @@ Six core capabilities — each moves from infrastructure toward real daily use: |---|---|---|---|---| | 1 | **Setup** | Available | Guided wizard, 6+ LLM providers with key validation, 6 channel types (Feishu / WeChat / Discord / Slack / Telegram / WhatsApp), profile switching | One-click environment migration ([#1](https://github.com/openmaster-ai/clawmaster/issues/1)), Windows + WSL2 first-class support | | 2 | **Observe** | Available | ClawProbe-backed dashboard, per-session cost and token tracking, gateway health monitoring | Historical spend analytics, anomaly alerts, multi-profile comparison | -| 3 | **Save** | In progress | Managed PowerMem runtime + OpenClaw bridge, local workspace import, first memory-backed skill — see [Memory Highlights](#memory-highlights) | Full seekdb hybrid retrieval, self-maintaining LLM Wiki — see [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) | +| 3 | **Save** | In progress | Managed PowerMem runtime + OpenClaw bridge, local workspace import, first memory-backed skill — see [Memory Highlights](#memory-highlights) | Full seekdb hybrid retrieval, self-maintaining LLM Knowledge module — see [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **Apply** | In progress | PaddleOCR pipeline (upload → parse → structured markdown), layout-aware extraction | Photo → flashcard automation, invoice extraction templates, more scenario-first guided workflows | | 5 | **Build** | Planned | Plugin/skill install and toggle, MCP server management, skill security auditing | Visual agent composer for skill chaining, LangChain Deep Agents integration, conversational agent builder | | 6 | **Guard** | Planned | Skill Guard security scanning (dimension/severity/risk scoring), basic capability gating | API key vault (encrypted at rest), per-profile spend caps, RBAC for team deployments | diff --git a/README_CN.md b/README_CN.md index a31a9cf..055caab 100644 --- a/README_CN.md +++ b/README_CN.md @@ -124,7 +124,7 @@ npm run tauri:dev # 桌面应用 - 首个端到端记忆驱动技能:每日 npm 包下载摘要,支持周期同比对比。 - 记忆相关的可观测:按会话花费、定时费用摘要、models.dev 定价。 -**下一步(v0.4.0)**:完整的 seekdb 混合检索,以及自维护的 LLM Wiki 模块 —— 每次投入都会让 Wiki 页面自动交叉链接并积累,艾宾浩斯衰减与新鲜度加权让内容保持“活着”。具体进展见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1)。 +**下一步(v0.4.0)**:完整的 seekdb 混合检索,以及自维护的 LLM 知识库模块 —— 每次写入都会让知识页面自动交叉链接并积累,艾宾浩斯衰减与新鲜度加权让内容保持"活着"。具体进展见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1)。 ## 产品功能总览 @@ -182,7 +182,7 @@ npm run tauri:dev # 桌面应用 |---|---|---|---|---| | 1 | **能接管** | 可用 | 引导式向导、6+ LLM 供应商并校验 Key、6 种频道(飞书 / 微信 / Discord / Slack / Telegram / WhatsApp)、Profile 切换 | 一键环境迁移、Windows + WSL2 一等支持 | | 2 | **能观测** | 可用 | 基于 ClawProbe 的面板、按会话的费用与 Token 追踪、网关健康监控 | 历史花费分析、异常告警、多 Profile 对比 | -| 3 | **能省钱** | 进行中 | 托管 PowerMem 运行时 + OpenClaw 桥接、本地工作区导入、首个记忆驱动技能 —— 详见[记忆亮点](#记忆亮点) | 完整 seekdb 混合检索、自维护 LLM Wiki —— 详见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1) | +| 3 | **能省钱** | 进行中 | 托管 PowerMem 运行时 + OpenClaw 桥接、本地工作区导入、首个记忆驱动技能 —— 详见[记忆亮点](#记忆亮点) | 完整 seekdb 混合检索、自维护 LLM 知识库模块 —— 详见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **能应用** | 进行中 | PaddleOCR 流水线(上传 → 解析 → 结构化 Markdown)、版面感知提取 | 拍照 → 闪卡自动生成、发票提取模板、更多场景优先的引导式工作流 | | 5 | **能构建** | 规划中 | 插件 / 技能安装与开关、MCP 服务管理、技能安全审计 | 可视化智能体编排器、LangChain Deep Agents 集成、对话式智能体构建 | | 6 | **能守护** | 规划中 | Skill Guard 安全扫描(维度 / 严重性 / 风险评分)、基础能力门控 | API Key 加密保险箱、按 Profile 的花费上限、团队部署 RBAC | diff --git a/README_JP.md b/README_JP.md index 663c4e2..af6336a 100644 --- a/README_JP.md +++ b/README_JP.md @@ -124,7 +124,7 @@ npm run tauri:dev # デスクトップアプリ - 管理対象メモリで動く初のエンドツーエンドスキル:npm ダウンロード日次ダイジェストと期間比較。 - メモリ近傍の可観測性:セッション単位のコスト、スケジュール済みコストダイジェスト、models.dev 価格情報。 -**次(v0.4.0)**:完全な seekdb ハイブリッド検索と、自己保守する LLM Wiki モジュール —— 取り込みごとに交差リンクされて積み上がる永続ページ、エビングハウス減衰と新鮮度重み付けで内容を生かし続けます。詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) を参照。 +**次(v0.4.0)**:完全な seekdb ハイブリッド検索と、自己保守する LLM ナレッジモジュール —— 取り込みごとに交差リンクされて積み上がる永続ページ、エビングハウス減衰と新鮮度重み付けで内容を生かし続けます。詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) を参照。 ## プロダクト機能ツアー @@ -182,7 +182,7 @@ npm run tauri:dev # デスクトップアプリ |---|---|---|---|---| | 1 | **能管理** | 利用可能 | ガイド付きウィザード、6+ LLM プロバイダー(キー検証付き)、6 チャンネル(Feishu / WeChat / Discord / Slack / Telegram / WhatsApp)、Profile 切り替え | ワンクリック環境移行、Windows + WSL2 ファーストクラスサポート | | 2 | **能観測** | 利用可能 | ClawProbe ベースのダッシュボード、セッションごとのコスト・トークン追跡、ゲートウェイヘルス監視 | 履歴コスト分析、異常アラート、マルチ Profile 比較 | -| 3 | **能節約** | 開発中 | 管理対象 PowerMem ランタイム + OpenClaw ブリッジ、ワークスペースインポート、初のメモリ駆動スキル —— 詳細は[メモリハイライト](#メモリハイライト) | 完全な seekdb ハイブリッド検索、自己保守 LLM Wiki —— 詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) | +| 3 | **能節約** | 開発中 | 管理対象 PowerMem ランタイム + OpenClaw ブリッジ、ワークスペースインポート、初のメモリ駆動スキル —— 詳細は[メモリハイライト](#メモリハイライト) | 完全な seekdb ハイブリッド検索、自己保守 LLM ナレッジモジュール —— 詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **能活用** | 開発中 | PaddleOCR パイプライン(アップロード → 解析 → 構造化 Markdown)、レイアウト認識抽出 | 写真 → フラッシュカード自動生成、請求書抽出テンプレート、シナリオ優先のガイド付きワークフロー | | 5 | **能構築** | 計画中 | プラグイン / スキルのインストール・切り替え、MCP サーバー管理、スキルセキュリティ監査 | ビジュアルエージェントコンポーザー、LangChain Deep Agents 統合、対話型エージェントビルダー | | 6 | **能守護** | 計画中 | Skill Guard セキュリティスキャン(次元 / 重大度 / リスクスコア)、基本的な機能ゲーティング | API キー暗号化ボールト、Profile ごとの支出上限、チームデプロイ向け RBAC | diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index e68d6fe..50d64f7 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -1184,7 +1184,7 @@ export default { "nav.mcp": "MCP Servers", "nav.channels": "Channels", "nav.memory": "Memory", - "nav.wiki": "Wiki", + "nav.wiki": "Knowledge", "nav.sessions": "Sessions", "nav.models": "Models", "nav.skills": "Skills", @@ -2006,72 +2006,72 @@ export default { "cron.scheduleCronValue": "Cron {{value}}", "cron.scheduleEveryValue": "Every {{value}}", "cron.scheduleAtValue": "At {{value}}", - "layout.page.wiki": "Browse compiled knowledge, inspect sources, and run wiki maintenance.", + "layout.page.wiki": "Browse the LLM-curated knowledge base, inspect sources, and run maintenance workflows.", "wiki.kicker": "Workspace knowledge", - "wiki.title": "Wiki", - "wiki.subtitle": "Search, inspect, ingest, and maintain compiled articles backed by managed PowerMem.", - "wiki.statPages": "Articles", + "wiki.title": "Knowledge", + "wiki.subtitle": "Search, query, and ingest sources into an LLM-curated knowledge base.", + "wiki.statPages": "Pages", "wiki.statSources": "Sources", "wiki.statStale": "Stale", - "wiki.statStaleHint": "Stale means Wiki evolution calculated a freshness score below the threshold. It usually means the content has not been updated recently, the source may be old, or health checks found signals worth reviewing.", + "wiki.statStaleHint": "Stale means evolution calculated a freshness score below the threshold. It usually means the content has not been updated recently, the source may be old, or health checks found signals worth reviewing.", "wiki.statEngine": "Memory engine", - "wiki.searchTitle": "Article Search", - "wiki.searchPlaceholder": "Search articles or ask what we know...", + "wiki.searchTitle": "Search", + "wiki.searchPlaceholder": "Search pages or ask what we know...", "wiki.search": "Search", "wiki.clearSearch": "Clear search", - "wiki.emptyPages": "No wiki articles yet.", - "wiki.searchFailed": "Wiki search failed.", - "wiki.loadFailed": "Wiki failed to load.", + "wiki.emptyPages": "No pages yet.", + "wiki.searchFailed": "Search failed.", + "wiki.loadFailed": "Knowledge base failed to load.", "wiki.ingestTitle": "Ingest Source", - "wiki.ingestTitlePlaceholder": "Article title", + "wiki.ingestTitlePlaceholder": "Page title", "wiki.ingestSourcePlaceholder": "Source URL or file path", - "wiki.ingestContentPlaceholder": "Markdown or notes to compile into the wiki", + "wiki.ingestContentPlaceholder": "Markdown or notes to ingest as a knowledge source", "wiki.ingest": "Ingest", - "wiki.ingestFailed": "Wiki ingest failed.", - "wiki.urlConfirmation": "URL ingest is explicit. Confirm before writing this link into the wiki.", + "wiki.ingestFailed": "Ingest failed.", + "wiki.urlConfirmation": "URL ingest is explicit. Confirm before adding this link to the knowledge base.", "wiki.confirmIngest": "Ingest URL", "wiki.summarizeOnce": "Summarize once", "wiki.currentConversationOnly": "Use once", - "wiki.summarizeOnceNotice": "The link will be used for a one-time summary and will not be saved to Wiki.", - "wiki.currentConversationOnlyNotice": "The link will stay in the current conversation only and will not be saved to Wiki.", - "wiki.selectPage": "Select a wiki article.", + "wiki.summarizeOnceNotice": "The link will be used for a one-time summary and will not be saved.", + "wiki.currentConversationOnlyNotice": "The link will stay in the current conversation only and will not be saved.", + "wiki.selectPage": "Select a page.", "wiki.backlinks": "Backlinks", "wiki.links": "Links", "wiki.citations": "Citations", "wiki.none": "None", - "wiki.queryTitle": "Ask Wiki", - "wiki.queryPlaceholder": "Ask a question against compiled knowledge", + "wiki.queryTitle": "Ask", + "wiki.queryPlaceholder": "Ask a question against the knowledge base", "wiki.ask": "Ask", - "wiki.queryFailed": "Wiki query failed.", - "wiki.offerToSave": "This answer can be saved as durable wiki knowledge after confirmation.", + "wiki.queryFailed": "Query failed.", + "wiki.offerToSave": "This answer can be saved as a durable knowledge page after confirmation.", "wiki.saveSynthesis": "Save synthesis", - "wiki.synthesizeFailed": "Wiki synthesis failed.", + "wiki.synthesizeFailed": "Synthesis failed.", "wiki.synthesisSaved": "Saved synthesis: {{title}}", "wiki.runLint": "Run Lint", "wiki.runEvolve": "Run Evolve", "wiki.runDeepEvolve": "Run Deep Evolve", - "wiki.lintFailed": "Wiki lint failed.", - "wiki.evolveFailed": "Wiki evolve failed.", - "wiki.deepEvolveFailed": "Wiki deep evolve failed.", + "wiki.lintFailed": "Lint failed.", + "wiki.evolveFailed": "Evolve failed.", + "wiki.deepEvolveFailed": "Deep evolve failed.", "wiki.lintSummary": "{{count}} issue(s) found.", - "wiki.evolveSummary": "Updated freshness for {{pages}} article(s); {{stale}} stale.", + "wiki.evolveSummary": "Updated freshness for {{pages}} page(s); {{stale}} stale.", "wiki.evolveDetails": "{{changed}} page(s) rewritten; {{conflicts}} conflict(s); {{related}} related-map entries.", "wiki.evolveChangedEvidence": "{{count}} page(s) gained new evolve evidence. Open a page to see what changed.", - "wiki.evolveCheckedOnly": "Evolve checked the corpus; no article improvement or metadata change was needed.", - "wiki.warning.conflictsDetected": "Wiki health found {{count}} conflict(s), usually missing wiki links, stale pages, or duplicate titles. Run Lint to inspect them.", + "wiki.evolveCheckedOnly": "Evolve checked the corpus; no page improvement or metadata change was needed.", + "wiki.warning.conflictsDetected": "Health check found {{count}} conflict(s), usually missing links, stale pages, or duplicate titles. Run Lint to inspect them.", "wiki.warning.autoEvolveFailed": "Automatic evolution did not finish; run Evolve manually to retry.", "wiki.evolution.changedTitle": "Changed by evolve", - "wiki.evolution.changedDetail": "Wiki recorded an article or metadata improvement at {{time}}.", + "wiki.evolution.changedDetail": "Recorded a page or metadata improvement at {{time}}.", "wiki.evolution.checkedTitle": "Checked by evolve", - "wiki.evolution.checkedDetail": "Wiki checked this page at {{time}} and left the article intact.", - "wiki.bonusTitle": "What Wiki already did for you", + "wiki.evolution.checkedDetail": "Checked this page at {{time}} and left the content intact.", + "wiki.bonusTitle": "What's already done for you", "wiki.bonusSubtitle": "These are useful outputs created or maintained from your sources without manual filing, tagging, or re-reading.", "wiki.bonusGenerated": "LLM synthesis pages", - "wiki.bonusGeneratedHint": "Cross-source pages generated from the Wiki, not raw articles.", + "wiki.bonusGeneratedHint": "Cross-source pages generated from the knowledge base, not raw sources.", "wiki.bonusSources": "Searchable source pages", "wiki.bonusSourcesHint": "Fetched sources stored with provenance for later answers.", "wiki.bonusChecked": "Auto-maintained pages", - "wiki.bonusCheckedHint": "{{changed}} maintenance update(s); {{evolved}} true evolved article(s).", + "wiki.bonusCheckedHint": "{{changed}} maintenance update(s); {{evolved}} truly evolved page(s).", "wiki.bonusIssues": "Health signals", "wiki.bonusIssuesHint": "Missing links, stale pages, or duplicates found automatically.", "wiki.sourceCount": "{{count}} source(s)", @@ -2088,7 +2088,7 @@ export default { "wiki.lifecycle.evolved": "Evolved", "wiki.lifecycle.outdated": "Outdated", "wiki.signal.freshnessTitle": "Freshness score: {{score}}%", - "wiki.signal.lifecycleTitle": "Article lifecycle", + "wiki.signal.lifecycleTitle": "Page lifecycle", "wiki.signal.changedAt": "Evolved {{time}}", "wiki.signal.ingestedAt": "Ingested", "wiki.signal.updatedAt": "Updated", @@ -2096,14 +2096,14 @@ export default { "wiki.signal.changedAtLabel": "Metadata changed by evolve", "wiki.signal.noEvolveChange": "No evolve change recorded", "wiki.signal.evidence": "Evolution evidence", - "wiki.signal.checkedOnly": "Checked for freshness, links, and health; no article improvement recorded.", + "wiki.signal.checkedOnly": "Checked for freshness, links, and health; no page improvement recorded.", "wiki.signal.evidenceSource": "Evidence source: {{source}}", "wiki.origin.llm": "LLM synthesis", - "wiki.origin.llmDetail": "Generated by Wiki from {{count}} source signal(s). This is the bonus layer: it turns multiple articles into one reusable answer surface.", + "wiki.origin.llmDetail": "Generated from {{count}} source signal(s). This is the bonus layer: it turns multiple pages into one reusable answer surface.", "wiki.origin.source": "Ingested source", - "wiki.origin.sourceDetail": "Captured from a URL, file, or note with provenance. It remains inspectable as a source article.", + "wiki.origin.sourceDetail": "Captured from a URL, file, or note with provenance. It remains inspectable as a source page.", "wiki.origin.maintained": "Maintained page", - "wiki.origin.maintainedDetail": "Tracked by Wiki for freshness, links, backlinks, and health signals.", + "wiki.origin.maintainedDetail": "Tracked for freshness, links, backlinks, and health signals.", "wiki.match.keyword": "Keyword", "wiki.match.semantic": "Semantic", } as const diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index 2c78609..09f626c 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -1184,7 +1184,7 @@ export default { "nav.mcp": "MCPサーバー", "nav.channels": "チャンネル", "nav.memory": "メモリ", - "nav.wiki": "Wiki", + "nav.wiki": "ナレッジ", "nav.sessions": "セッション", "nav.models": "モデル", "nav.skills": "スキル", @@ -2006,72 +2006,72 @@ export default { "cron.scheduleCronValue": "Cron {{value}}", "cron.scheduleEveryValue": "{{value}} ごと", "cron.scheduleAtValue": "{{value}} に実行", - "layout.page.wiki": "蓄積された知識を閲覧し、ソースを確認し、Wiki の保守を実行します。", + "layout.page.wiki": "LLM が整理した知識を閲覧し、ソースを確認し、保守ワークフローを実行します。", "wiki.kicker": "ワークスペース知識", - "wiki.title": "Wiki", - "wiki.subtitle": "管理 PowerMem を基盤にした記事を検索、閲覧、取り込み、保守します。", - "wiki.statPages": "記事", + "wiki.title": "ナレッジ", + "wiki.subtitle": "LLM が整理したナレッジベースを検索、質問、取り込みます。", + "wiki.statPages": "ページ", "wiki.statSources": "ソース", - "wiki.statStale": "古い記事", - "wiki.statStaleHint": "古い記事は、Wiki の Evolve が算出した鮮度スコアがしきい値を下回った状態です。通常は長期間更新されていない、ソースが古い可能性がある、または確認すべきヘルスシグナルがあることを示します。", + "wiki.statStale": "古いページ", + "wiki.statStaleHint": "古いページは、Evolve が算出した鮮度スコアがしきい値を下回った状態です。通常は長期間更新されていない、ソースが古い可能性がある、または確認すべきヘルスシグナルがあることを示します。", "wiki.statEngine": "メモリエンジン", - "wiki.searchTitle": "記事検索", - "wiki.searchPlaceholder": "記事を検索、または既知のことを質問...", + "wiki.searchTitle": "検索", + "wiki.searchPlaceholder": "ページを検索、または既知のことを質問...", "wiki.search": "検索", "wiki.clearSearch": "検索をクリア", - "wiki.emptyPages": "Wiki 記事はまだありません。", - "wiki.searchFailed": "Wiki 検索に失敗しました。", - "wiki.loadFailed": "Wiki の読み込みに失敗しました。", + "wiki.emptyPages": "ページはまだありません。", + "wiki.searchFailed": "検索に失敗しました。", + "wiki.loadFailed": "ナレッジベースの読み込みに失敗しました。", "wiki.ingestTitle": "ソース取り込み", - "wiki.ingestTitlePlaceholder": "記事タイトル", + "wiki.ingestTitlePlaceholder": "ページタイトル", "wiki.ingestSourcePlaceholder": "ソース URL またはファイルパス", - "wiki.ingestContentPlaceholder": "Wiki に編み込む Markdown またはメモ", + "wiki.ingestContentPlaceholder": "ナレッジベースに取り込む Markdown またはメモ", "wiki.ingest": "取り込み", - "wiki.ingestFailed": "Wiki 取り込みに失敗しました。", - "wiki.urlConfirmation": "URL の取り込みは明示的な確認が必要です。確認後に Wiki へ書き込みます。", + "wiki.ingestFailed": "取り込みに失敗しました。", + "wiki.urlConfirmation": "URL の取り込みは明示的な確認が必要です。確認後にナレッジベースへ追加します。", "wiki.confirmIngest": "URL を取り込む", "wiki.summarizeOnce": "一度だけ要約", "wiki.currentConversationOnly": "今回だけ使用", - "wiki.summarizeOnceNotice": "このリンクは一度だけの要約に使われ、Wiki には保存されません。", - "wiki.currentConversationOnlyNotice": "このリンクは現在の会話だけで使われ、Wiki には保存されません。", - "wiki.selectPage": "Wiki 記事を選択してください。", + "wiki.summarizeOnceNotice": "このリンクは一度だけの要約に使われ、保存されません。", + "wiki.currentConversationOnlyNotice": "このリンクは現在の会話だけで使われ、保存されません。", + "wiki.selectPage": "ページを選択してください。", "wiki.backlinks": "バックリンク", "wiki.links": "リンク", "wiki.citations": "引用", "wiki.none": "なし", - "wiki.queryTitle": "Wiki に質問", - "wiki.queryPlaceholder": "蓄積済み知識に質問する", + "wiki.queryTitle": "質問", + "wiki.queryPlaceholder": "ナレッジベースに質問する", "wiki.ask": "質問", - "wiki.queryFailed": "Wiki クエリに失敗しました。", - "wiki.offerToSave": "確認後、この回答を永続的な Wiki 知識として保存できます。", + "wiki.queryFailed": "クエリに失敗しました。", + "wiki.offerToSave": "確認後、この回答を永続的なナレッジページとして保存できます。", "wiki.saveSynthesis": "統合ページを保存", - "wiki.synthesizeFailed": "Wiki 統合生成に失敗しました。", + "wiki.synthesizeFailed": "統合生成に失敗しました。", "wiki.synthesisSaved": "統合ページを保存しました: {{title}}", "wiki.runLint": "Lint を実行", "wiki.runEvolve": "Evolve を実行", "wiki.runDeepEvolve": "Deep Evolve を実行", - "wiki.lintFailed": "Wiki Lint に失敗しました。", - "wiki.evolveFailed": "Wiki Evolve に失敗しました。", - "wiki.deepEvolveFailed": "Wiki Deep Evolve に失敗しました。", + "wiki.lintFailed": "Lint に失敗しました。", + "wiki.evolveFailed": "Evolve に失敗しました。", + "wiki.deepEvolveFailed": "Deep Evolve に失敗しました。", "wiki.lintSummary": "{{count}} 件の問題が見つかりました。", - "wiki.evolveSummary": "{{pages}} 件の記事の鮮度を更新しました。古い記事: {{stale}} 件。", + "wiki.evolveSummary": "{{pages}} 件のページの鮮度を更新しました。古いページ: {{stale}} 件。", "wiki.evolveDetails": "{{changed}} 件のページを書き換え、{{conflicts}} 件の競合、{{related}} 件の関連マップを更新しました。", "wiki.evolveChangedEvidence": "{{count}} 件のページに新しい Evolve 根拠が追加されました。ページを開くと変更内容を確認できます。", - "wiki.evolveCheckedOnly": "Evolve はコーパスを確認しました。記事改善またはメタデータ変更は不要でした。", - "wiki.warning.conflictsDetected": "Wiki ヘルスチェックで {{count}} 件の競合が見つかりました。多くは不足している Wiki リンク、古いページ、重複タイトルです。Lint を実行すると詳細を確認できます。", + "wiki.evolveCheckedOnly": "Evolve はコーパスを確認しました。ページ改善またはメタデータ変更は不要でした。", + "wiki.warning.conflictsDetected": "ヘルスチェックで {{count}} 件の競合が見つかりました。多くは不足リンク、古いページ、重複タイトルです。Lint を実行すると詳細を確認できます。", "wiki.warning.autoEvolveFailed": "自動 Evolve が完了しませんでした。手動で Evolve を実行して再試行できます。", "wiki.evolution.changedTitle": "Evolve による改善あり", - "wiki.evolution.changedDetail": "Wiki は {{time}} に記事またはメタデータの改善を記録しました。", + "wiki.evolution.changedDetail": "{{time}} にページまたはメタデータの改善を記録しました。", "wiki.evolution.checkedTitle": "Evolve による確認済み", - "wiki.evolution.checkedDetail": "Wiki は {{time}} にこのページを確認し、記事本文はそのまま残しました。", - "wiki.bonusTitle": "Wiki がすでに代行した作業", + "wiki.evolution.checkedDetail": "{{time}} にこのページを確認し、内容はそのまま残しました。", + "wiki.bonusTitle": "すでに代行された作業", "wiki.bonusSubtitle": "手動の整理、タグ付け、読み直しなしで、ソースから自動作成または保守された価値です。", "wiki.bonusGenerated": "LLM 統合ページ", - "wiki.bonusGeneratedHint": "生記事ではなく、Wiki が複数ソースから生成したページです。", + "wiki.bonusGeneratedHint": "生ソースではなく、複数ソースから生成されたページです。", "wiki.bonusSources": "検索可能なソースページ", "wiki.bonusSourcesHint": "後続の回答で参照できるよう、出典付きで保存されたソースです。", "wiki.bonusChecked": "自動保守ページ", - "wiki.bonusCheckedHint": "{{changed}} 件の保守更新。{{evolved}} 件の真の Evolve 記事。", + "wiki.bonusCheckedHint": "{{changed}} 件の保守更新。{{evolved}} 件の真に Evolve したページ。", "wiki.bonusIssues": "健全性シグナル", "wiki.bonusIssuesHint": "欠落リンク、古いページ、重複を自動検出します。", "wiki.sourceCount": "{{count}} 件のソース", @@ -2088,7 +2088,7 @@ export default { "wiki.lifecycle.evolved": "Evolve 済み", "wiki.lifecycle.outdated": "古い", "wiki.signal.freshnessTitle": "鮮度スコア: {{score}}%", - "wiki.signal.lifecycleTitle": "記事ライフサイクル", + "wiki.signal.lifecycleTitle": "ページライフサイクル", "wiki.signal.changedAt": "Evolve {{time}}", "wiki.signal.ingestedAt": "取り込み日時", "wiki.signal.updatedAt": "更新日時", @@ -2096,14 +2096,14 @@ export default { "wiki.signal.changedAtLabel": "Evolve メタデータ変更日時", "wiki.signal.noEvolveChange": "Evolve による変更記録なし", "wiki.signal.evidence": "Evolve の根拠", - "wiki.signal.checkedOnly": "鮮度、リンク、健全性を確認しました。記事改善は記録されていません。", + "wiki.signal.checkedOnly": "鮮度、リンク、健全性を確認しました。ページ改善は記録されていません。", "wiki.signal.evidenceSource": "根拠ソース: {{source}}", "wiki.origin.llm": "LLM 統合", - "wiki.origin.llmDetail": "Wiki が {{count}} 件のソースシグナルから生成しました。複数の記事を再利用できる回答面に変える追加価値です。", + "wiki.origin.llmDetail": "{{count}} 件のソースシグナルから生成しました。複数のページを再利用できる回答面に変える追加価値です。", "wiki.origin.source": "取り込みソース", - "wiki.origin.sourceDetail": "URL、ファイル、メモから出典付きで取り込まれました。ソース記事として確認できます。", + "wiki.origin.sourceDetail": "URL、ファイル、メモから出典付きで取り込まれました。ソースページとして確認できます。", "wiki.origin.maintained": "保守対象ページ", - "wiki.origin.maintainedDetail": "Wiki が鮮度、リンク、バックリンク、健全性シグナルを追跡しています。", + "wiki.origin.maintainedDetail": "鮮度、リンク、バックリンク、健全性シグナルを追跡しています。", "wiki.match.keyword": "キーワード", "wiki.match.semantic": "セマンティック", } as const diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index 4089689..ea27671 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -1184,7 +1184,7 @@ export default { "nav.mcp": "MCP 服务", "nav.channels": "通道", "nav.memory": "记忆", - "nav.wiki": "Wiki", + "nav.wiki": "知识库", "nav.sessions": "会话", "nav.models": "模型", "nav.skills": "技能", @@ -2006,72 +2006,72 @@ export default { "cron.scheduleCronValue": "Cron {{value}}", "cron.scheduleEveryValue": "每 {{value}}", "cron.scheduleAtValue": "于 {{value}} 执行", - "layout.page.wiki": "浏览沉淀后的知识,检查来源,并运行 Wiki 维护。", + "layout.page.wiki": "浏览由 LLM 沉淀的知识库,检查来源,并运行维护工作流。", "wiki.kicker": "工作区知识", - "wiki.title": "Wiki", - "wiki.subtitle": "搜索、查看、写入并维护由托管 PowerMem 支撑的编译文章。", - "wiki.statPages": "文章", + "wiki.title": "知识库", + "wiki.subtitle": "搜索、提问、将来源写入由 LLM 沉淀的知识库。", + "wiki.statPages": "页面", "wiki.statSources": "来源", "wiki.statStale": "过期", - "wiki.statStaleHint": "过期表示 Wiki 进化计算出的新鲜度分数低于阈值。通常是内容太久未更新、来源可能已变旧,或巡检发现需要复核的健康信号。", + "wiki.statStaleHint": "过期表示进化计算出的新鲜度分数低于阈值。通常是内容太久未更新、来源可能已变旧,或巡检发现需要复核的健康信号。", "wiki.statEngine": "记忆引擎", - "wiki.searchTitle": "文章搜索", - "wiki.searchPlaceholder": "搜索文章,或询问我们知道什么...", + "wiki.searchTitle": "搜索", + "wiki.searchPlaceholder": "搜索页面,或询问我们知道什么...", "wiki.search": "搜索", "wiki.clearSearch": "清除搜索", - "wiki.emptyPages": "还没有 Wiki 文章。", - "wiki.searchFailed": "Wiki 搜索失败。", - "wiki.loadFailed": "Wiki 加载失败。", + "wiki.emptyPages": "还没有页面。", + "wiki.searchFailed": "搜索失败。", + "wiki.loadFailed": "知识库加载失败。", "wiki.ingestTitle": "写入来源", - "wiki.ingestTitlePlaceholder": "文章标题", + "wiki.ingestTitlePlaceholder": "页面标题", "wiki.ingestSourcePlaceholder": "来源 URL 或文件路径", - "wiki.ingestContentPlaceholder": "要编译进 Wiki 的 Markdown 或笔记", + "wiki.ingestContentPlaceholder": "要作为来源写入知识库的 Markdown 或笔记", "wiki.ingest": "写入", - "wiki.ingestFailed": "Wiki 写入失败。", - "wiki.urlConfirmation": "URL 写入必须显式确认。确认后才会把这个链接写入 Wiki。", + "wiki.ingestFailed": "写入失败。", + "wiki.urlConfirmation": "URL 写入必须显式确认。确认后才会把这个链接加入知识库。", "wiki.confirmIngest": "写入 URL", "wiki.summarizeOnce": "仅总结一次", "wiki.currentConversationOnly": "仅本轮使用", - "wiki.summarizeOnceNotice": "该链接只用于一次性总结,不会保存到 Wiki。", - "wiki.currentConversationOnlyNotice": "该链接只保留在当前对话,不会保存到 Wiki。", - "wiki.selectPage": "请选择一篇 Wiki 文章。", + "wiki.summarizeOnceNotice": "该链接只用于一次性总结,不会保存。", + "wiki.currentConversationOnlyNotice": "该链接只保留在当前对话,不会保存。", + "wiki.selectPage": "请选择一个页面。", "wiki.backlinks": "反向链接", "wiki.links": "链接", "wiki.citations": "引用", "wiki.none": "无", - "wiki.queryTitle": "询问 Wiki", - "wiki.queryPlaceholder": "基于已沉淀知识提问", + "wiki.queryTitle": "提问", + "wiki.queryPlaceholder": "基于知识库提问", "wiki.ask": "提问", - "wiki.queryFailed": "Wiki 查询失败。", - "wiki.offerToSave": "确认后可将这个回答保存为持久 Wiki 知识。", + "wiki.queryFailed": "查询失败。", + "wiki.offerToSave": "确认后可将这个回答保存为持久知识页面。", "wiki.saveSynthesis": "保存综合页", - "wiki.synthesizeFailed": "Wiki 综合生成失败。", + "wiki.synthesizeFailed": "综合生成失败。", "wiki.synthesisSaved": "已保存综合页:{{title}}", "wiki.runLint": "运行巡检", "wiki.runEvolve": "运行进化", "wiki.runDeepEvolve": "运行深度进化", - "wiki.lintFailed": "Wiki 巡检失败。", - "wiki.evolveFailed": "Wiki 进化失败。", - "wiki.deepEvolveFailed": "Wiki 深度进化失败。", + "wiki.lintFailed": "巡检失败。", + "wiki.evolveFailed": "进化失败。", + "wiki.deepEvolveFailed": "深度进化失败。", "wiki.lintSummary": "发现 {{count}} 个问题。", - "wiki.evolveSummary": "已更新 {{pages}} 篇文章的新鲜度;{{stale}} 篇过期。", + "wiki.evolveSummary": "已更新 {{pages}} 个页面的新鲜度;{{stale}} 个过期。", "wiki.evolveDetails": "已重写 {{changed}} 个页面;{{conflicts}} 个冲突;{{related}} 条关联映射。", "wiki.evolveChangedEvidence": "{{count}} 个页面获得新的进化证据。打开页面可查看具体变化。", - "wiki.evolveCheckedOnly": "进化已检查整个语料;没有需要记录的文章改进或元数据变化。", - "wiki.warning.conflictsDetected": "Wiki 健康检查发现 {{count}} 个冲突,通常是缺失 Wiki 链接、过期页或重复标题。运行巡检可查看详情。", + "wiki.evolveCheckedOnly": "进化已检查整个语料;没有需要记录的页面改进或元数据变化。", + "wiki.warning.conflictsDetected": "健康检查发现 {{count}} 个冲突,通常是缺失链接、过期页或重复标题。运行巡检可查看详情。", "wiki.warning.autoEvolveFailed": "自动进化没有完成;可手动运行进化重试。", "wiki.evolution.changedTitle": "进化已改进", - "wiki.evolution.changedDetail": "Wiki 在 {{time}} 记录了文章或元数据改进。", + "wiki.evolution.changedDetail": "在 {{time}} 记录了页面或元数据改进。", "wiki.evolution.checkedTitle": "进化已检查", - "wiki.evolution.checkedDetail": "Wiki 在 {{time}} 检查了此页面,并保持文章内容不变。", - "wiki.bonusTitle": "Wiki 已经替你完成的工作", + "wiki.evolution.checkedDetail": "在 {{time}} 检查了此页面,并保持内容不变。", + "wiki.bonusTitle": "已经替你完成的工作", "wiki.bonusSubtitle": "这些是系统从你的来源自动创建或维护的价值,不需要你手动归档、打标签或重新阅读。", "wiki.bonusGenerated": "LLM 综合页", - "wiki.bonusGeneratedHint": "由 Wiki 跨来源生成,不是原始文章。", + "wiki.bonusGeneratedHint": "跨来源生成的页面,不是原始来源。", "wiki.bonusSources": "可搜索来源页", "wiki.bonusSourcesHint": "已保存来源和出处,可被后续问答引用。", "wiki.bonusChecked": "自动维护页", - "wiki.bonusCheckedHint": "{{changed}} 次维护更新;{{evolved}} 篇文章真正进化。", + "wiki.bonusCheckedHint": "{{changed}} 次维护更新;{{evolved}} 个页面真正进化。", "wiki.bonusIssues": "健康信号", "wiki.bonusIssuesHint": "自动发现缺失链接、过期页或重复项。", "wiki.sourceCount": "{{count}} 个来源", @@ -2088,7 +2088,7 @@ export default { "wiki.lifecycle.evolved": "已进化", "wiki.lifecycle.outdated": "已过期", "wiki.signal.freshnessTitle": "新鲜度分数:{{score}}%", - "wiki.signal.lifecycleTitle": "文章生命周期", + "wiki.signal.lifecycleTitle": "页面生命周期", "wiki.signal.changedAt": "进化于 {{time}}", "wiki.signal.ingestedAt": "写入时间", "wiki.signal.updatedAt": "更新时间", @@ -2096,14 +2096,14 @@ export default { "wiki.signal.changedAtLabel": "进化元数据变更时间", "wiki.signal.noEvolveChange": "未记录进化变更", "wiki.signal.evidence": "进化证据", - "wiki.signal.checkedOnly": "已检查新鲜度、链接和健康状态;未记录文章改进。", + "wiki.signal.checkedOnly": "已检查新鲜度、链接和健康状态;未记录页面改进。", "wiki.signal.evidenceSource": "证据来源:{{source}}", "wiki.origin.llm": "LLM 综合", - "wiki.origin.llmDetail": "由 Wiki 基于 {{count}} 个来源信号生成。这是额外收益层:把多篇文章变成一个可复用回答面。", + "wiki.origin.llmDetail": "基于 {{count}} 个来源信号生成。这是额外收益层:把多个页面变成一个可复用回答面。", "wiki.origin.source": "写入来源", - "wiki.origin.sourceDetail": "来自 URL、文件或笔记,并保留出处。它仍可作为来源文章被检查。", + "wiki.origin.sourceDetail": "来自 URL、文件或笔记,并保留出处。它仍可作为来源页面被检查。", "wiki.origin.maintained": "托管页面", - "wiki.origin.maintainedDetail": "由 Wiki 跟踪新鲜度、链接、反向链接和健康信号。", + "wiki.origin.maintainedDetail": "持续跟踪新鲜度、链接、反向链接和健康信号。", "wiki.match.keyword": "关键词", "wiki.match.semantic": "语义", } as const diff --git a/packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx b/packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx index 182dd9f..612c503 100644 --- a/packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx +++ b/packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx @@ -208,14 +208,14 @@ describe('WikiPage', () => { it('renders status, article search results, and opens article details in a modal', async () => { render() - expect(await screen.findByRole('heading', { name: 'Wiki' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Stale means Wiki evolution/i })).toBeInTheDocument() - const bonusSection = await screen.findByRole('heading', { name: 'What Wiki already did for you' }) + expect(await screen.findByRole('heading', { name: 'Knowledge' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Stale means evolution/i })).toBeInTheDocument() + const bonusSection = await screen.findByRole('heading', { name: "What's already done for you" }) const bonusCard = bonusSection.closest('section') as HTMLElement expect(within(bonusCard).getByText('LLM synthesis pages')).toBeInTheDocument() expect(within(bonusCard).getByText('Searchable source pages')).toBeInTheDocument() expect(within(bonusCard).getByText('Auto-maintained pages')).toBeInTheDocument() - expect(within(bonusCard).getByText('1 maintenance update(s); 1 true evolved article(s).')).toBeInTheDocument() + expect(within(bonusCard).getByText('1 maintenance update(s); 1 truly evolved page(s).')).toBeInTheDocument() expect(within(bonusCard).getByText('Health signals')).toBeInTheDocument() expect(await screen.findByText('PowerMem Bridge')).toBeInTheDocument() expect(await screen.findByText('Powermem Knowledge')).toBeInTheDocument() @@ -237,7 +237,7 @@ describe('WikiPage', () => { fireEvent.click(within(detailDialog).getByRole('button', { name: /Close/i })) await waitFor(() => expect(screen.queryByRole('dialog', { name: 'PowerMem Bridge' })).not.toBeInTheDocument()) - fireEvent.change(screen.getByPlaceholderText('Search articles or ask what we know...'), { + fireEvent.change(screen.getByPlaceholderText('Search pages or ask what we know...'), { target: { value: 'PowerMem' }, }) fireEvent.click(screen.getByRole('button', { name: /Search/i })) @@ -251,14 +251,14 @@ describe('WikiPage', () => { render() expect(await screen.findByText('PowerMem Bridge')).toBeInTheDocument() - fireEvent.change(screen.getByPlaceholderText('Search articles or ask what we know...'), { + fireEvent.change(screen.getByPlaceholderText('Search pages or ask what we know...'), { target: { value: 'missing topic' }, }) fireEvent.click(screen.getByRole('button', { name: /Search/i })) await waitFor(() => expect(mockWikiSearch).toHaveBeenCalledWith('missing topic', { limit: 20 })) - const searchSection = screen.getByRole('heading', { name: 'Article Search' }).closest('section') as HTMLElement - expect(within(searchSection).getByText('No wiki articles yet.')).toBeInTheDocument() + const searchSection = screen.getByRole('heading', { name: 'Search' }).closest('section') as HTMLElement + expect(within(searchSection).getByText('No pages yet.')).toBeInTheDocument() expect(within(searchSection).queryByText('PowerMem Bridge')).not.toBeInTheDocument() fireEvent.click(within(searchSection).getByRole('button', { name: /Clear search/i })) expect(await within(searchSection).findByText('PowerMem Bridge')).toBeInTheDocument() @@ -275,7 +275,7 @@ describe('WikiPage', () => { expect(screen.getByRole('button', { name: /Summarize once/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Use once/i })).toBeInTheDocument() - fireEvent.change(screen.getByPlaceholderText('Ask a question against compiled knowledge'), { + fireEvent.change(screen.getByPlaceholderText('Ask a question against the knowledge base'), { target: { value: 'what do we know about powermem?' }, }) fireEvent.click(screen.getByRole('button', { name: /^Ask$/i })) @@ -287,7 +287,7 @@ describe('WikiPage', () => { limit: 5, })) expect(await screen.findByText(/Saved synthesis: Powermem/i)).toBeInTheDocument() - expect(await screen.findByText(/Updated freshness for 2 article/i)).toBeInTheDocument() + expect(await screen.findByText(/Updated freshness for 2 page/i)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Run Lint/i })) expect(await screen.findByText(/1 issue/i)).toBeInTheDocument() @@ -295,8 +295,8 @@ describe('WikiPage', () => { fireEvent.click(screen.getByRole('button', { name: /Run Evolve/i })) await waitFor(() => expect(mockWikiEvolve).toHaveBeenCalled()) - expect(await screen.findByText(/Updated freshness for 1 article/i)).toBeInTheDocument() - expect(await screen.findByText(/Wiki health found 0 conflict/i)).toBeInTheDocument() + expect(await screen.findByText(/Updated freshness for 1 page/i)).toBeInTheDocument() + expect(await screen.findByText(/Health check found 0 conflict/i)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Run Deep Evolve/i })) await waitFor(() => expect(mockWikiDeepEvolve).toHaveBeenCalled()) From a2950a62d62cdaac3ca50e507ea0b6916cb553d4 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sat, 9 May 2026 23:13:09 +0800 Subject: [PATCH 2/7] refactor(wiki): share vault path and structure resolver with powermem plugin Two layers used to own the same on-disk wiki vault: wikiService derived the path from the OpenClaw profile selection, the PowerMem plugin derived it from the managed data root. They converged for the default profile but could drift under non-trivial configurations (named profile without a data-root override, differing env handling), and each layer wrote the vault scaffold (SCHEMA.md, .meta/*.json, index.md, log.md) independently. Changes: - packages/backend/src/openclawVaultPaths.ts (new): single source of truth for vault-root resolution and directory/meta scaffolding. Precedence: explicit override > CLAWMASTER_WIKI_ROOT env > OPENCLAW_STATE_DIR env > data-root-derived state dir > profile selection. Exports resolveWikiVaultRoot, resolveWikiVaultLayout, ensureWikiVaultStructure, and WIKI_SCHEMA_MARKDOWN. - packages/backend/src/openclawVaultPaths.test.ts (new): matrix tests across override / env / data-root / profile inputs, plus a parity check asserting the plugin's join(resolveOpenclawWorkspaceDir(ctx), '..', 'wiki') lands on the same vault root as the shared helper. - packages/backend/src/services/wikiService.ts: delete the local resolveOpenclawStateDir + WIKI_SCHEMA_TEMPLATE + inline directory ensure logic; delegate resolveWikiPaths and ensureWikiVault to the shared module. Drop the stealth SCHEMA.md force-overwrite block (obsolete migration). - plugins/memory-clawmaster-powermem/index.ts: honor CLAWMASTER_WIKI_ROOT in resolveWikiVaultRoot for parity with the backend (no other behavior change). --- .../backend/src/openclawVaultPaths.test.ts | 249 ++++++++++++++++++ packages/backend/src/openclawVaultPaths.ts | 222 ++++++++++++++++ packages/backend/src/services/wikiService.ts | 134 +++------- plugins/memory-clawmaster-powermem/index.ts | 2 + 4 files changed, 506 insertions(+), 101 deletions(-) create mode 100644 packages/backend/src/openclawVaultPaths.test.ts create mode 100644 packages/backend/src/openclawVaultPaths.ts diff --git a/packages/backend/src/openclawVaultPaths.test.ts b/packages/backend/src/openclawVaultPaths.test.ts new file mode 100644 index 0000000..23b161d --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + ensureWikiVaultStructure, + resolveOpenclawStateDirForVault, + resolveOpenclawStateDirFromManagedDataRoot, + resolveWikiVaultLayout, + resolveWikiVaultRoot, + WIKI_SCHEMA_MARKDOWN, +} from './openclawVaultPaths.js' + +function emptyEnv(): Record { + return {} +} + +test('resolveWikiVaultRoot honors explicit vault root override before all other inputs', () => { + const vaultRoot = resolveWikiVaultRoot({ + vaultRootOverride: '/tmp/explicit-vault', + env: { OPENCLAW_STATE_DIR: '/tmp/other', CLAWMASTER_WIKI_ROOT: '/tmp/env-override' }, + homeDir: '/home/testuser', + profileSelection: { kind: 'named', name: 'work' }, + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(vaultRoot, '/tmp/explicit-vault') +}) + +test('resolveWikiVaultRoot honors CLAWMASTER_WIKI_ROOT env when no explicit override is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { CLAWMASTER_WIKI_ROOT: '/tmp/env-vault' }, + homeDir: '/home/testuser', + }) + assert.equal(vaultRoot, '/tmp/env-vault') +}) + +test('resolveWikiVaultRoot prefers OPENCLAW_STATE_DIR over profile selection', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { OPENCLAW_STATE_DIR: '/opt/openclaw-state' }, + homeDir: '/home/testuser', + profileSelection: { kind: 'named', name: 'work' }, + platform: 'linux', + }) + assert.equal(vaultRoot, path.posix.join('/opt/openclaw-state', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a named-profile state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a dev state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/dev', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw-dev', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a default state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/default', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw', 'wiki')) +}) + +test('resolveWikiVaultRoot falls back to profile selection + home when no overrides are supplied', () => { + const defaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'default' }, + }) + assert.equal(defaultRoot, path.posix.join('/home/testuser/.openclaw', 'wiki')) + + const devRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'dev' }, + }) + assert.equal(devRoot, path.posix.join('/home/testuser/.openclaw-dev', 'wiki')) + + const namedRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'named', name: 'work' }, + }) + assert.equal(namedRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) +}) + +test('resolveWikiVaultRoot uses windows path semantics when a windows data root is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + platform: 'win32', + dataRootOverride: 'C:\\Users\\dev\\.clawmaster\\data\\default', + }) + assert.equal(vaultRoot, path.win32.join('C:\\Users\\dev\\.openclaw', 'wiki')) +}) + +test('resolveWikiVaultRoot prefers /home/ over /mnt/c/users/ when WSL data root is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { HOME: '/home/wsluser' }, + platform: 'linux', + dataRootOverride: '/mnt/c/users/dev/.clawmaster/data/default', + }) + assert.equal(vaultRoot, path.posix.join('/home/wsluser/.openclaw', 'wiki')) +}) + +test('resolveOpenclawStateDirFromManagedDataRoot returns null for unrecognised managed data roots', () => { + assert.equal( + resolveOpenclawStateDirFromManagedDataRoot('/tmp/something/else', {}), + null, + ) + assert.equal( + resolveOpenclawStateDirFromManagedDataRoot('', {}), + null, + ) +}) + +test('resolveOpenclawStateDirForVault keeps OPENCLAW_STATE_DIR precedence over dataRootOverride', () => { + const stateDir = resolveOpenclawStateDirForVault({ + env: { OPENCLAW_STATE_DIR: '/opt/override' }, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(stateDir, '/opt/override') +}) + +test('resolveWikiVaultLayout returns all derived paths below the resolved vault root', () => { + const layout = resolveWikiVaultLayout({ + vaultRootOverride: '/tmp/vault', + platform: 'linux', + }) + assert.equal(layout.vaultRoot, '/tmp/vault') + assert.equal(layout.rawRoot, path.posix.join('/tmp/vault', 'raw')) + assert.equal(layout.pagesRoot, path.posix.join('/tmp/vault', 'pages')) + assert.equal(layout.metaRoot, path.posix.join('/tmp/vault', '.meta')) + assert.equal(layout.indexPath, path.posix.join('/tmp/vault', 'index.md')) + assert.equal(layout.logPath, path.posix.join('/tmp/vault', 'log.md')) + assert.equal(layout.schemaPath, path.posix.join('/tmp/vault', 'SCHEMA.md')) + assert.equal(layout.freshnessPath, path.posix.join('/tmp/vault/.meta', 'freshness.json')) + assert.equal(layout.conflictsPath, path.posix.join('/tmp/vault/.meta', 'conflicts.json')) + assert.equal(layout.ingestStatePath, path.posix.join('/tmp/vault/.meta', 'ingest-state.json')) +}) + +test('ensureWikiVaultStructure creates directories, meta files, and the shared schema markdown', async () => { + const vaultRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-vault-')) + try { + const layout = resolveWikiVaultLayout({ vaultRootOverride: vaultRoot }) + await ensureWikiVaultStructure(layout) + + for (const subdir of ['raw', '.meta', 'pages/sources', 'pages/entities', 'pages/concepts', 'pages/synthesis', 'pages/processes']) { + const stat = await fs.stat(path.join(vaultRoot, subdir)) + assert.ok(stat.isDirectory(), `${subdir} should be a directory`) + } + + const schema = await fs.readFile(layout.schemaPath, 'utf8') + assert.equal(schema, `${WIKI_SCHEMA_MARKDOWN}\n`) + + const freshness = await fs.readFile(layout.freshnessPath, 'utf8') + assert.equal(freshness.trim(), '{}') + + const conflicts = await fs.readFile(layout.conflictsPath, 'utf8') + assert.equal(conflicts.trim(), '[]') + + const state = JSON.parse(await fs.readFile(layout.ingestStatePath, 'utf8')) as { + version: number + sources: Record + } + assert.equal(state.version, 1) + assert.deepEqual(state.sources, {}) + } finally { + await fs.rm(vaultRoot, { recursive: true, force: true }) + } +}) + +test('ensureWikiVaultStructure is idempotent and does not overwrite existing content', async () => { + const vaultRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-vault-idempotent-')) + try { + const layout = resolveWikiVaultLayout({ vaultRootOverride: vaultRoot }) + await ensureWikiVaultStructure(layout) + + const customSchema = '# Custom schema (user edited)\n' + await fs.writeFile(layout.schemaPath, customSchema, 'utf8') + await fs.writeFile(layout.logPath, '# User custom log\n\n- a personal log entry\n', 'utf8') + await fs.writeFile(layout.freshnessPath, '{"foo":1}\n', 'utf8') + + await ensureWikiVaultStructure(layout) + + assert.equal(await fs.readFile(layout.schemaPath, 'utf8'), customSchema) + assert.match(await fs.readFile(layout.logPath, 'utf8'), /personal log entry/) + assert.equal(JSON.parse(await fs.readFile(layout.freshnessPath, 'utf8')).foo, 1) + } finally { + await fs.rm(vaultRoot, { recursive: true, force: true }) + } +}) + +test('resolveWikiVaultRoot matches the plugin-side resolution for representative managed data roots (parity)', () => { + const cases = [ + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/default', + expected: '/home/testuser/.openclaw/wiki', + }, + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + expected: '/home/testuser/.openclaw-work/wiki', + }, + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/dev', + expected: '/home/testuser/.openclaw-dev/wiki', + }, + ] + + for (const { platform, homeDir, dataRootOverride, expected } of cases) { + const backendResolution = resolveWikiVaultRoot({ + env: emptyEnv(), + platform, + homeDir, + dataRootOverride, + }) + assert.equal(backendResolution, expected) + + // Plugin resolution today is `resolveOpenclawWorkspaceDir(ctx)` joined with '../wiki'. + // Proving the derived-state-dir matches our shared helper is enough to guarantee + // the plugin's join(..., '..', 'wiki') lands on the same vault root without + // creating a circular plugin→backend import in production code. + const stateDir = resolveOpenclawStateDirFromManagedDataRoot(dataRootOverride, {}) + assert.equal(stateDir, expected.replace(/\/wiki$/, '')) + } +}) diff --git a/packages/backend/src/openclawVaultPaths.ts b/packages/backend/src/openclawVaultPaths.ts new file mode 100644 index 0000000..e9c5719 --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + getOpenclawPathModule, + getOpenclawProfileSelection, + type OpenclawProfileContext, + type OpenclawProfileSelection, +} from './openclawProfile.js' + +export interface VaultPathContext extends OpenclawProfileContext { + profileSelection?: OpenclawProfileSelection + vaultRootOverride?: string + dataRootOverride?: string + env?: Record +} + +export const WIKI_SCHEMA_MARKDOWN = `# Wiki Schema + +This vault stores durable, citation-aware knowledge for OpenClaw and ClawMaster workflows. + +## Layout + +- \`raw/\`: original imported artifacts or fetched source payloads when stored later +- \`pages/sources/\`: imported source notes and source-linked summaries +- \`pages/entities/\`: people, orgs, tools, and products enriched from sources +- \`pages/concepts/\`: patterns, ideas, and reusable techniques enriched from sources +- \`pages/synthesis/\`: durable synthesized answers created from source pages +- \`pages/processes/\`: process docs and operating procedures +- \`.meta/freshness.json\`: computed freshness state +- \`.meta/conflicts.json\`: lint issues considered knowledge conflicts +- \`.meta/ingest-state.json\`: source-to-page provenance for incremental re-ingest + +## Required frontmatter + +- \`id\`: stable page id +- \`title\`: human-readable page title +- \`type\`: one of entity, concept, source, synthesis, process +- \`createdAt\`, \`updatedAt\` +- \`freshnessScore\`, \`freshnessStatus\` +- \`memoryId\`: managed memory backing record id when present + +## Generated provenance + +- \`generatedFromSourceIds\`: pipe-delimited source page ids whose generated blocks contribute to the page +- Generated blocks use HTML comments of the form \`\` +- Re-ingest replaces or removes only the generated block for the matching source page id + +## Linking and citations + +- Use \`[[Page Title]]\` wiki-style links for cross-references +- Source pages should preserve provenance via \`sourceUrl\` and \`sourcePath\` +- Synthesis pages should cite source pages with \`[[Page Title]]\` links + +## Maintenance + +- Mechanical evolve recalculates freshness, related pages, and structural health +- Deep evolve is opt-in and may revise stale pages with LLM review +- Lint checks structure first, then optional contradiction checks across related pages +` + +const PAGE_SUBDIRS = ['sources', 'entities', 'concepts', 'synthesis', 'processes'] as const + +function isWindowsDrivePath(value: string): boolean { + return /^[A-Za-z]:\//.test(value) +} + +function toForwardSlashes(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function fromForwardSlashes(value: string, windowsStyle: boolean): string { + return windowsStyle ? value.replace(/\//g, '\\') : value +} + +function resolvePreferredPosixHomeForMountedManagedDataRoot( + normalizedDataRoot: string, + env: Record, +): string | null { + const homeDir = env['HOME']?.trim() + if (!homeDir || !homeDir.startsWith('/home/')) return null + if (!/^\/mnt\/[a-z]\/users\/[^/]+\/\.clawmaster\/data\//i.test(normalizedDataRoot)) return null + return homeDir +} + +export function resolveOpenclawStateDirFromManagedDataRoot( + dataRoot: string, + env: Record = process.env, +): string | null { + const normalized = toForwardSlashes(dataRoot.trim()) + if (!normalized) return null + + const windowsStyle = isWindowsDrivePath(normalized) + const preferredPosixHome = !windowsStyle + ? resolvePreferredPosixHomeForMountedManagedDataRoot(normalized, env) + : null + + const namedMatch = /^(.*)\/\.clawmaster\/data\/named\/([^/]+)$/.exec(normalized) + if (namedMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(namedMatch[1]!, windowsStyle) + const profileName = namedMatch[2]! + return windowsStyle + ? path.win32.join(homeDir, `.openclaw-${profileName}`) + : path.posix.join(homeDir, `.openclaw-${profileName}`) + } + + const devMatch = /^(.*)\/\.clawmaster\/data\/dev$/.exec(normalized) + if (devMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(devMatch[1]!, windowsStyle) + return windowsStyle + ? path.win32.join(homeDir, '.openclaw-dev') + : path.posix.join(homeDir, '.openclaw-dev') + } + + const defaultMatch = /^(.*)\/\.clawmaster\/data\/default$/.exec(normalized) + if (defaultMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(defaultMatch[1]!, windowsStyle) + return windowsStyle + ? path.win32.join(homeDir, '.openclaw') + : path.posix.join(homeDir, '.openclaw') + } + + return null +} + +function resolveStateDirFromProfile( + profileSelection: OpenclawProfileSelection, + context: VaultPathContext, +): string { + const pathModule = getOpenclawPathModule(context.platform) + const homeDir = context.homeDir ?? os.homedir() + if (profileSelection.kind === 'named' && profileSelection.name) { + return pathModule.join(homeDir, `.openclaw-${profileSelection.name}`) + } + if (profileSelection.kind === 'dev') { + return pathModule.join(homeDir, '.openclaw-dev') + } + return pathModule.join(homeDir, '.openclaw') +} + +export function resolveOpenclawStateDirForVault(context: VaultPathContext = {}): string { + const env = context.env ?? process.env + const stateDirEnv = env['OPENCLAW_STATE_DIR']?.trim() + if (stateDirEnv) return stateDirEnv + + if (context.dataRootOverride) { + const derived = resolveOpenclawStateDirFromManagedDataRoot(context.dataRootOverride, env) + if (derived) return derived + } + + const profileSelection = context.profileSelection ?? getOpenclawProfileSelection(context) + return resolveStateDirFromProfile(profileSelection, context) +} + +export function resolveWikiVaultRoot(context: VaultPathContext = {}): string { + const env = context.env ?? process.env + if (context.vaultRootOverride?.trim()) return context.vaultRootOverride.trim() + + const envOverride = env['CLAWMASTER_WIKI_ROOT']?.trim() + if (envOverride) return envOverride + + const stateDir = resolveOpenclawStateDirForVault(context) + const pathModule = getOpenclawPathModule(context.platform) + return pathModule.join(stateDir, 'wiki') +} + +export interface WikiVaultLayout { + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string + ingestStatePath: string +} + +export function resolveWikiVaultLayout(context: VaultPathContext = {}): WikiVaultLayout { + const vaultRoot = resolveWikiVaultRoot(context) + const pathModule = getOpenclawPathModule(context.platform) + const pagesRoot = pathModule.join(vaultRoot, 'pages') + const metaRoot = pathModule.join(vaultRoot, '.meta') + return { + vaultRoot, + rawRoot: pathModule.join(vaultRoot, 'raw'), + pagesRoot, + metaRoot, + indexPath: pathModule.join(vaultRoot, 'index.md'), + logPath: pathModule.join(vaultRoot, 'log.md'), + schemaPath: pathModule.join(vaultRoot, 'SCHEMA.md'), + freshnessPath: pathModule.join(metaRoot, 'freshness.json'), + conflictsPath: pathModule.join(metaRoot, 'conflicts.json'), + ingestStatePath: pathModule.join(metaRoot, 'ingest-state.json'), + } +} + +async function writeIfMissing(filePath: string, content: string): Promise { + try { + await fs.access(filePath) + } catch { + await fs.writeFile(filePath, content, 'utf8') + } +} + +export async function ensureWikiVaultStructure(layout: WikiVaultLayout): Promise { + await fs.mkdir(layout.rawRoot, { recursive: true }) + await fs.mkdir(layout.metaRoot, { recursive: true }) + await Promise.all( + PAGE_SUBDIRS.map((dir) => fs.mkdir(path.join(layout.pagesRoot, dir), { recursive: true })), + ) + await writeIfMissing(layout.indexPath, '# Knowledge Index\n\nPages will appear here after ingest.\n') + await writeIfMissing(layout.logPath, '# Knowledge Log\n\n') + await writeIfMissing(layout.schemaPath, `${WIKI_SCHEMA_MARKDOWN}\n`) + await writeIfMissing(layout.freshnessPath, '{}\n') + await writeIfMissing(layout.conflictsPath, '[]\n') + await writeIfMissing( + layout.ingestStatePath, + `${JSON.stringify({ version: 1, sources: {} }, null, 2)}\n`, + ) +} diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts index 10dcb98..baf3ee3 100644 --- a/packages/backend/src/services/wikiService.ts +++ b/packages/backend/src/services/wikiService.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import fs from 'node:fs/promises' -import os from 'node:os' import path from 'node:path' import { addManagedMemory, @@ -11,11 +10,15 @@ import { type ManagedMemoryContext, } from './managedMemory.js' import { - getOpenclawPathModule, getOpenclawProfileSelection, type OpenclawProfileContext, type OpenclawProfileSelection, } from '../openclawProfile.js' +import { + ensureWikiVaultStructure, + resolveWikiVaultLayout, + type WikiVaultLayout, +} from '../openclawVaultPaths.js' import { wikiLlmComplete, wikiLlmCompleteStructured, @@ -278,56 +281,12 @@ const PAGE_DIRS: Record = { process: 'processes', } -const WIKI_STATE_FILE = 'ingest-state.json' const DEFAULT_FRESHNESS_SCORE = 1 const GENERATED_BLOCK_PREFIX = 'CLAWMASTER-GENERATED' const DERIVED_PAGE_CONFIDENCE_THRESHOLD = 0.72 const MAX_INGEST_LLM_CHARS = 12_000 const MAX_DEEP_EVOLVE_PAGES = 5 const MAX_CONTRADICTION_PAIRS = 10 -const WIKI_SCHEMA_TEMPLATE = `# Wiki Schema - -This vault stores durable, citation-aware wiki knowledge for OpenClaw and ClawMaster workflows. - -## Layout - -- \`raw/\`: original imported artifacts or fetched source payloads when stored later -- \`pages/sources/\`: imported source notes and source-linked summaries -- \`pages/entities/\`: people, orgs, tools, and products enriched from sources -- \`pages/concepts/\`: patterns, ideas, and reusable techniques enriched from sources -- \`pages/synthesis/\`: durable synthesized answers created from source pages -- \`pages/processes/\`: process docs and operating procedures -- \`.meta/freshness.json\`: computed freshness state -- \`.meta/conflicts.json\`: lint issues considered wiki conflicts -- \`.meta/ingest-state.json\`: source-to-page provenance for incremental re-ingest - -## Required frontmatter - -- \`id\`: stable page id -- \`title\`: human-readable page title -- \`type\`: one of entity, concept, source, synthesis, process -- \`createdAt\`, \`updatedAt\` -- \`freshnessScore\`, \`freshnessStatus\` -- \`memoryId\`: managed memory backing record id when present - -## Generated provenance - -- \`generatedFromSourceIds\`: pipe-delimited source page ids whose generated blocks contribute to the page -- Generated blocks use HTML comments of the form \`\` -- Re-ingest replaces or removes only the generated block for the matching source page id - -## Linking and citations - -- Use \`[[Wiki Links]]\` for page references -- Source pages should preserve provenance via \`sourceUrl\` and \`sourcePath\` -- Synthesis pages should cite source pages with \`[[Page Title]]\` links - -## Maintenance - -- Mechanical evolve recalculates freshness, related pages, and structural health -- Deep evolve is opt-in and may revise stale pages with LLM review -- Lint checks structure first, then optional contradiction checks across related pages -` function nowIso(): string { return new Date().toISOString() @@ -450,45 +409,30 @@ function getProfileKey(profileSelection: OpenclawProfileSelection): string { return profileSelection.kind } -function resolveOpenclawStateDir( - profileSelection: OpenclawProfileSelection, - context: WikiServiceContext, -): string { - const pathModule = getOpenclawPathModule(context.platform) - const homeDir = context.homeDir ?? os.homedir() - if (profileSelection.kind === 'named' && profileSelection.name) { - return pathModule.join(homeDir, `.openclaw-${profileSelection.name}`) - } - if (profileSelection.kind === 'dev') { - return pathModule.join(homeDir, '.openclaw-dev') - } - return pathModule.join(homeDir, '.openclaw') -} - export function resolveWikiPaths(context: WikiServiceContext = {}): WikiPaths { const profileSelection = context.profileSelection ?? getOpenclawProfileSelection(context) - const vaultRoot = - context.vaultRootOverride - ?? process.env['CLAWMASTER_WIKI_ROOT']?.trim() - ?? path.join(resolveOpenclawStateDir(profileSelection, context), 'wiki') - const pagesRoot = path.join(vaultRoot, 'pages') - const metaRoot = path.join(vaultRoot, '.meta') + const layout: WikiVaultLayout = resolveWikiVaultLayout({ + homeDir: context.homeDir, + platform: context.platform, + profileSelection, + vaultRootOverride: context.vaultRootOverride, + }) return { profileKey: getProfileKey(profileSelection), - vaultRoot, - rawRoot: path.join(vaultRoot, 'raw'), - pagesRoot, - metaRoot, - indexPath: path.join(vaultRoot, 'index.md'), - logPath: path.join(vaultRoot, 'log.md'), - schemaPath: path.join(vaultRoot, 'SCHEMA.md'), - freshnessPath: path.join(metaRoot, 'freshness.json'), - conflictsPath: path.join(metaRoot, 'conflicts.json'), + vaultRoot: layout.vaultRoot, + rawRoot: layout.rawRoot, + pagesRoot: layout.pagesRoot, + metaRoot: layout.metaRoot, + indexPath: layout.indexPath, + logPath: layout.logPath, + schemaPath: layout.schemaPath, + freshnessPath: layout.freshnessPath, + conflictsPath: layout.conflictsPath, } } function statePath(paths: WikiPaths): string { - return path.join(paths.metaRoot, WIKI_STATE_FILE) + return path.join(paths.metaRoot, 'ingest-state.json') } async function pathExists(targetPath: string): Promise { @@ -507,30 +451,18 @@ async function writeIfMissing(filePath: string, content: string): Promise export async function ensureWikiVault(context: WikiServiceContext = {}): Promise { const paths = resolveWikiPaths(context) - await fs.mkdir(paths.rawRoot, { recursive: true }) - await fs.mkdir(paths.metaRoot, { recursive: true }) - await Promise.all( - Object.values(PAGE_DIRS).map((dir) => fs.mkdir(path.join(paths.pagesRoot, dir), { recursive: true })), - ) - await writeIfMissing( - paths.indexPath, - '# Wiki Index\n\nCompiled wiki articles will appear here after ingest.\n', - ) - await writeIfMissing( - paths.logPath, - '# Wiki Log\n\n', - ) - await writeIfMissing( - paths.schemaPath, - `${WIKI_SCHEMA_TEMPLATE}\n`, - ) - const existingSchema = await fs.readFile(paths.schemaPath, 'utf8').catch(() => '') - if (existingSchema.trim() === '# Wiki Schema\n\nPages use YAML frontmatter with id, title, type, source, freshness, and provenance fields.'.trim()) { - await fs.writeFile(paths.schemaPath, `${WIKI_SCHEMA_TEMPLATE}\n`, 'utf8') - } - await writeIfMissing(paths.freshnessPath, '{}\n') - await writeIfMissing(paths.conflictsPath, '[]\n') - await writeIfMissing(statePath(paths), `${JSON.stringify({ version: 1, sources: {} }, null, 2)}\n`) + await ensureWikiVaultStructure({ + vaultRoot: paths.vaultRoot, + rawRoot: paths.rawRoot, + pagesRoot: paths.pagesRoot, + metaRoot: paths.metaRoot, + indexPath: paths.indexPath, + logPath: paths.logPath, + schemaPath: paths.schemaPath, + freshnessPath: paths.freshnessPath, + conflictsPath: paths.conflictsPath, + ingestStatePath: statePath(paths), + }) return paths } diff --git a/plugins/memory-clawmaster-powermem/index.ts b/plugins/memory-clawmaster-powermem/index.ts index de3424e..2ffce78 100644 --- a/plugins/memory-clawmaster-powermem/index.ts +++ b/plugins/memory-clawmaster-powermem/index.ts @@ -655,6 +655,8 @@ export async function writeWikiMetaFilesForTest(vaultRoot: string, pageId: strin } function resolveWikiVaultRoot(managedContext: ManagedMemoryContext): string { + const explicitOverride = process.env['CLAWMASTER_WIKI_ROOT']?.trim() + if (explicitOverride) return explicitOverride return join(resolveOpenclawWorkspaceDir(managedContext), '..', 'wiki') } From bd245b030cdaa77e83ba1108057bfaa6c1fa51c9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 10 May 2026 11:24:55 +0800 Subject: [PATCH 3/7] refactor(wiki): migrate list frontmatter to yaml arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generatedFromSourceIds, relatedPages, and relatedPageIds now persist as YAML inline arrays on disk (e.g. `generatedFromSourceIds: ["a", "b"]`) instead of pipe-delimited strings. External markdown editors render these as native lists and can query them with dataview-style tools without treating the value as a single opaque string. The in-memory shape remains Record — parseFrontmatter pipe-joins array values on read so existing call sites keep working, and renderFrontmatter splits them back out on write for any key in LIST_FRONTMATTER_KEYS. A small set of helpers (readListFrontmatter, serializeFrontmatterList, normalizeFrontmatterValueForWrite) replaces the ad-hoc parsePipeList / serializePipeList pair. Source pages no longer write a body-level "## Extracted Wiki Links" generated block. The derived page titles now flow into a `relatedPages` frontmatter array; re-ingest of any legacy page strips the old block via removeGeneratedBlock before writing. summarizeParsedPages unions frontmatter relatedPages into the page link graph so backlinks and orphan detection still see the derived-entity edges. The shared SCHEMA.md template documents the new array convention. The wikiService test asserts the raw on-disk file carries YAML-array syntax and that the legacy body section is absent. --- packages/backend/src/openclawVaultPaths.ts | 6 +- .../backend/src/services/wikiService.test.ts | 17 ++ packages/backend/src/services/wikiService.ts | 152 ++++++++++++------ 3 files changed, 122 insertions(+), 53 deletions(-) diff --git a/packages/backend/src/openclawVaultPaths.ts b/packages/backend/src/openclawVaultPaths.ts index e9c5719..fde0492 100644 --- a/packages/backend/src/openclawVaultPaths.ts +++ b/packages/backend/src/openclawVaultPaths.ts @@ -42,13 +42,15 @@ This vault stores durable, citation-aware knowledge for OpenClaw and ClawMaster ## Generated provenance -- \`generatedFromSourceIds\`: pipe-delimited source page ids whose generated blocks contribute to the page +- \`generatedFromSourceIds\`: YAML array of source page ids whose generated blocks contribute to this page (e.g. \`["sources-foo", "sources-bar"]\`) +- \`relatedPages\`: YAML array of related page titles derived from this source (replaces the legacy body-level "Extracted Wiki Links" section) - Generated blocks use HTML comments of the form \`\` - Re-ingest replaces or removes only the generated block for the matching source page id ## Linking and citations -- Use \`[[Page Title]]\` wiki-style links for cross-references +- Use \`[[Page Title]]\` wiki-style links for cross-references in body text +- Frontmatter \`relatedPages\` arrays are unioned with body \`[[links]]\` for backlink calculations and are visible to dataview-style queries in external editors - Source pages should preserve provenance via \`sourceUrl\` and \`sourcePath\` - Synthesis pages should cite source pages with \`[[Page Title]]\` links diff --git a/packages/backend/src/services/wikiService.test.ts b/packages/backend/src/services/wikiService.test.ts index b44e512..cb12a36 100644 --- a/packages/backend/src/services/wikiService.test.ts +++ b/packages/backend/src/services/wikiService.test.ts @@ -254,6 +254,23 @@ test('llm ingest creates derived pages and removes outdated generated pages on r assert.equal(derivedPage.frontmatter.generatedFromSourceIds, first.page!.id) assert.match(derivedPage.content, /PowerMem Source/) + const derivedRaw = await fs.readFile(derivedPage.path, 'utf8') + assert.match( + derivedRaw, + new RegExp(`generatedFromSourceIds: \\["${first.page!.id}"\\]`), + 'derived page should persist generatedFromSourceIds as a YAML array on disk', + ) + + const sourcePage = await getWikiPage(first.page!.id, context) + assert.equal(sourcePage.frontmatter.relatedPages, 'SeekDB Runtime') + const sourceRaw = await fs.readFile(sourcePage.path, 'utf8') + assert.match( + sourceRaw, + /relatedPages: \["SeekDB Runtime"\]/, + 'source page should persist relatedPages as a YAML array on disk', + ) + assert.doesNotMatch(sourceRaw, /## Extracted Wiki Links/) + const second = await ingestWikiSource( { title: 'PowerMem Source', diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts index baf3ee3..a886202 100644 --- a/packages/backend/src/services/wikiService.ts +++ b/packages/backend/src/services/wikiService.ts @@ -332,25 +332,6 @@ function removeGeneratedBlock(body: string, blockId: string): string { return upsertGeneratedBlock(body, blockId, '') } -function parsePipeList(value: string | undefined): string[] { - return (value ?? '') - .split('|') - .map((item) => item.trim()) - .filter(Boolean) -} - -function serializePipeList(values: string[]): string { - return [...new Set(values.map((value) => value.trim()).filter(Boolean))].join('|') -} - -function buildSourceLinksBlock(links: string[]): string { - if (links.length === 0) return '' - return [ - '## Extracted Wiki Links', - '', - ...links.map((link) => `- [[${link}]]`), - ].join('\n') -} function buildDerivedContributionBlock(sourceTitle: string, summary: string): string { return [ @@ -613,16 +594,61 @@ async function fetchUrlContent(sourceUrl: string): Promise<{ title?: string; con } } -function renderFrontmatter(values: Record): string { +type FrontmatterScalar = string | number | boolean | null | undefined +type FrontmatterValue = FrontmatterScalar | string[] + +function isFilledArray(value: FrontmatterValue): value is string[] { + return Array.isArray(value) +} + +function renderFrontmatter(values: Record): string { const lines = Object.entries(values) - .filter(([, value]) => value !== undefined && value !== null && value !== '') + .filter(([, value]) => { + if (value === undefined || value === null) return false + if (typeof value === 'string' && value === '') return false + if (Array.isArray(value) && value.length === 0) return false + return true + }) .map(([key, value]) => { if (typeof value === 'number' || typeof value === 'boolean') return `${key}: ${value}` + if (isFilledArray(value)) { + const items = value.map((item) => JSON.stringify(String(item))).join(', ') + return `${key}: [${items}]` + } return `${key}: ${JSON.stringify(String(value))}` }) return `---\n${lines.join('\n')}\n---\n` } +function parseFrontmatterScalar(rawValue: string): string { + if (rawValue.startsWith('[') && rawValue.endsWith(']')) { + try { + const parsed = JSON.parse(rawValue) as unknown + if (Array.isArray(parsed)) { + return parsed + .map((item) => String(item).trim()) + .filter(Boolean) + .join('|') + } + } catch { + /* fall through to plain string parsing */ + } + } + if (/^-?\d+$/.test(rawValue) && !Number.isSafeInteger(Number(rawValue))) return rawValue + try { + const parsed = JSON.parse(rawValue) as unknown + if (Array.isArray(parsed)) { + return parsed + .map((item) => String(item).trim()) + .filter(Boolean) + .join('|') + } + return String(parsed) + } catch { + return rawValue + } +} + function parseFrontmatter(raw: string): { frontmatter: Record; body: string } { if (!raw.startsWith('---')) return { frontmatter: {}, body: raw } const end = raw.indexOf('\n---', 3) @@ -636,25 +662,42 @@ function parseFrontmatter(raw: string): { frontmatter: Record; b const key = line.slice(0, index).trim() const rawValue = line.slice(index + 1).trim() if (!key) continue - if (/^-?\d+$/.test(rawValue) && !Number.isSafeInteger(Number(rawValue))) { - frontmatter[key] = rawValue - continue - } - try { - const parsed = JSON.parse(rawValue) as unknown - frontmatter[key] = String(parsed) - } catch { - frontmatter[key] = rawValue - } + frontmatter[key] = parseFrontmatterScalar(rawValue) } return { frontmatter, body } } +const LIST_FRONTMATTER_KEYS = new Set(['generatedFromSourceIds', 'relatedPages', 'relatedPageIds']) + +function normalizeFrontmatterValueForWrite(key: string, value: FrontmatterValue): FrontmatterValue { + if (Array.isArray(value)) return value + if (LIST_FRONTMATTER_KEYS.has(key) && typeof value === 'string' && value.length > 0) { + return value.split('|').map((item) => item.trim()).filter(Boolean) + } + return value +} + function renderMarkdownWithFrontmatter( - frontmatter: Record, + frontmatter: Record, body: string, ): string { - return `${renderFrontmatter(frontmatter)}\n${body.replace(/^\r?\n/, '')}` + const normalized: Record = {} + for (const [key, value] of Object.entries(frontmatter)) { + normalized[key] = normalizeFrontmatterValueForWrite(key, value) + } + return `${renderFrontmatter(normalized)}\n${body.replace(/^\r?\n/, '')}` +} + +function readListFrontmatter(value: string | undefined): string[] { + if (!value) return [] + return value + .split('|') + .map((item) => item.trim()) + .filter(Boolean) +} + +function serializeFrontmatterList(values: string[]): string { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].join('|') } function extractWikiLinks(content: string): string[] { @@ -858,6 +901,12 @@ function pageTypeFromRelativePath(relativePath: string): WikiPageType { return 'source' } +function linksForPage(page: ParsedPage): string[] { + const bodyLinks = extractWikiLinks(page.body) + const frontmatterLinks = readListFrontmatter(page.frontmatter.relatedPages) + return [...new Set([...bodyLinks, ...frontmatterLinks])] +} + function summarizeParsedPages(pages: ParsedPage[], query = '', freshnessMeta: WikiFreshnessMeta = {}): WikiPageSummary[] { const titleToId = new Map() for (const page of pages) { @@ -870,7 +919,7 @@ function summarizeParsedPages(pages: ParsedPage[], query = '', freshnessMeta: Wi const backlinksById = new Map>() for (const page of pages) { const fromId = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) - for (const link of extractWikiLinks(page.body)) { + for (const link of linksForPage(page)) { const targetId = titleToId.get(link) ?? slugify(link) if (!backlinksById.has(targetId)) backlinksById.set(targetId, new Set()) backlinksById.get(targetId)!.add(fromId) @@ -906,7 +955,7 @@ function summarizeParsedPages(pages: ParsedPage[], query = '', freshnessMeta: Wi evolveChangeSummary: page.frontmatter.evolveChangeSummary || '', evolveSource: page.frontmatter.evolveSource || '', lastAccessedAt: meta?.lastAccessedAt || page.frontmatter.lastAccessedAt || '', - links: extractWikiLinks(page.body), + links: linksForPage(page), backlinks: [...(backlinksById.get(id) ?? new Set())], memoryIds: page.frontmatter.memoryId ? [page.frontmatter.memoryId] : [], } @@ -925,6 +974,7 @@ function renderWikiPage(input: { fingerprint: string createdAt: string updatedAt: string + relatedPages?: string[] }): string { const fm = renderFrontmatter({ id: input.id, @@ -941,6 +991,7 @@ function renderWikiPage(input: { freshnessScore: DEFAULT_FRESHNESS_SCORE, freshnessStatus: 'fresh', sourceCount: input.sourcePath || input.sourceUrl ? 1 : 0, + relatedPages: input.relatedPages && input.relatedPages.length > 0 ? input.relatedPages : undefined, createdAt: input.createdAt, updatedAt: input.updatedAt, }) @@ -1015,7 +1066,7 @@ async function syncPageManagedMemory( sourcePath: parsed.frontmatter.sourcePath, sourceUrl: parsed.frontmatter.sourceUrl, sourceFingerprint: parsed.frontmatter.fingerprint, - sourcePageIds: parsePipeList(parsed.frontmatter.generatedFromSourceIds), + sourcePageIds: readListFrontmatter(parsed.frontmatter.generatedFromSourceIds), importedAt: nowIso(), createdBy: parsed.frontmatter.evolveSource?.includes('llm') ? 'wiki-llm' : 'wiki-service', }, @@ -1131,8 +1182,8 @@ async function upsertDerivedPage( const contribution = buildDerivedContributionBlock(sourcePage.title, item.summary) const existingRaw = await fs.readFile(pagePath, 'utf8').catch(() => '') const existingParsed = existingRaw ? parseFrontmatter(existingRaw) : null - const currentSourceIds = parsePipeList(existingParsed?.frontmatter.generatedFromSourceIds) - const nextSourceIds = serializePipeList([...currentSourceIds, sourcePage.id]) + const currentSourceIds = readListFrontmatter(existingParsed?.frontmatter.generatedFromSourceIds) + const nextSourceIds = serializeFrontmatterList([...currentSourceIds, sourcePage.id]) const nextBody = existingParsed ? upsertGeneratedBlock( ensureContributionSection(stripHeading(existingParsed.frontmatter.title || item.name, existingParsed.body)), @@ -1200,7 +1251,7 @@ async function cleanupRemovedDerivedPages( continue } const blockId = blockIdForSource(sourcePageId) - const nextSourceIds = parsePipeList(page.frontmatter.generatedFromSourceIds).filter((id) => id !== sourcePageId) + const nextSourceIds = readListFrontmatter(page.frontmatter.generatedFromSourceIds).filter((id) => id !== sourcePageId) const nextBody = pruneEmptyContributionSection(removeGeneratedBlock(stripHeading(page.title, page.content), blockId)) const generatedShell = generatedDerivedPageIntro(page.type) const remainingMeaningfulBody = nextBody @@ -1220,7 +1271,7 @@ async function cleanupRemovedDerivedPages( } const nextFrontmatter = { ...page.frontmatter, - generatedFromSourceIds: serializePipeList(nextSourceIds), + generatedFromSourceIds: serializeFrontmatterList(nextSourceIds), updatedAt: nowIso(), } await fs.writeFile(page.path, renderMarkdownWithFrontmatter(nextFrontmatter, nextBody), 'utf8') @@ -1494,15 +1545,13 @@ export async function ingestWikiSource( const uniqueSuggestions = [...new Map( derivedSuggestions.map((item) => [`${item.kind}:${item.name.toLowerCase()}`, item]), ).values()] - const sourceContent = upsertGeneratedBlock( - content, - `${GENERATED_BLOCK_PREFIX}:related-links`, - buildSourceLinksBlock( - uniqueSuggestions - .map((item) => item.existingTitle?.trim() || item.name.trim()) - .filter(Boolean), - ), - ) + // Drop any legacy `## Extracted Wiki Links` body block left over from earlier + // versions so re-ingest cleans it up; the same information now lives in the + // `relatedPages` YAML frontmatter array, which does not clutter editors. + const relatedPageTitles = uniqueSuggestions + .map((item) => item.existingTitle?.trim() || item.name.trim()) + .filter(Boolean) + const sourceContent = removeGeneratedBlock(content, `${GENERATED_BLOCK_PREFIX}:related-links`) const now = nowIso() const created = await addManagedMemory( @@ -1559,6 +1608,7 @@ export async function ingestWikiSource( fingerprint, createdAt: existingPage?.createdAt || previous?.updatedAt || now, updatedAt: now, + relatedPages: relatedPageTitles, }), 'utf8', ) @@ -2390,7 +2440,7 @@ async function collectDeepEvolveRelatedPages( page: WikiPageDetail, context: WikiServiceContext, ): Promise { - const queue = parsePipeList(page.frontmatter.relatedPageIds) + const queue = readListFrontmatter(page.frontmatter.relatedPageIds) const seen = new Set([page.id]) const relatedPages: WikiPageDetail[] = [] @@ -2401,7 +2451,7 @@ async function collectDeepEvolveRelatedPages( const relatedPage = await getWikiPage(relatedId, context).catch(() => null) if (!relatedPage) continue relatedPages.push(relatedPage) - for (const nestedId of parsePipeList(relatedPage.frontmatter.relatedPageIds)) { + for (const nestedId of readListFrontmatter(relatedPage.frontmatter.relatedPageIds)) { if (!seen.has(nestedId)) queue.push(nestedId) } } From c7d92a672886d9cacc5ace00e7dea99cc4a9efdd Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Tue, 12 May 2026 11:24:02 +0800 Subject: [PATCH 4/7] fix(wiki): replace fetch-identity transport sniffing with explicit resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wikiLlm.ts previously decided between gateway-fetch and infer-model by comparing globalThis.fetch against a module-load-time reference. Any APM agent, polyfill, or test harness that wraps fetch would silently divert production traffic to the gateway-fetch path, bypassing the intended CLI transport. Changes: - Drop the nativeFetch capture and shouldUseMockedFetchTransport logic. Transport resolution is now: explicit test override > WIKI_LLM_USE_GATEWAY env ('1' → gateway-fetch) > default (infer-model). WIKI_LLM_USE_GATEWAY is the only opt-in outside of tests. - Introduce setWikiLlmTransportForTests(transport|null) as the primary test seam. setWikiLlmUseGatewayFetchForTests is kept as a deprecated shim so existing test files compile unchanged during the transition. - Add tests asserting: default routes to infer-model without calling fetch; WIKI_LLM_USE_GATEWAY=1 forces gateway-fetch; explicit override beats the env. - Replace bare catch {} blocks in wikiService.ts LLM call sites with logWikiLlmFailure() calls that emit console.warn with the error message and a structured context object. Warning code payloads are preserved. - Simplify sanitizeWikiBody: the two CLAWMASTER-GENERATED-specific comment strips are subsumed by the single generic comment-strip regex. --- packages/backend/src/services/wikiLlm.test.ts | 75 ++++++++++++++++++- packages/backend/src/services/wikiLlm.ts | 48 +++++++----- packages/backend/src/services/wikiService.ts | 36 ++++++--- 3 files changed, 128 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/services/wikiLlm.test.ts b/packages/backend/src/services/wikiLlm.test.ts index 7680262..80aa339 100644 --- a/packages/backend/src/services/wikiLlm.test.ts +++ b/packages/backend/src/services/wikiLlm.test.ts @@ -6,12 +6,14 @@ import test from 'node:test' import { resolveWikiLlm, setWikiLlmCommandRunnerForTests, + setWikiLlmTransportForTests, setWikiLlmUseGatewayFetchForTests, wikiLlmComplete, wikiLlmEnabled, } from './wikiLlm.js' const originalFetch = globalThis.fetch +const originalUseGatewayEnv = process.env.WIKI_LLM_USE_GATEWAY async function createHomeRoot(name: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `clawmaster-wiki-llm-${name}-`)) @@ -26,7 +28,9 @@ async function writeConfig(homeDir: string, config: Record): Pr test.afterEach(() => { globalThis.fetch = originalFetch setWikiLlmCommandRunnerForTests(null) - setWikiLlmUseGatewayFetchForTests(false) + setWikiLlmTransportForTests(null) + if (originalUseGatewayEnv === undefined) delete process.env.WIKI_LLM_USE_GATEWAY + else process.env.WIKI_LLM_USE_GATEWAY = originalUseGatewayEnv }) test('resolveWikiLlm disables the helper when no default model is configured', async () => { @@ -119,3 +123,72 @@ test('wikiLlmComplete uses the supported infer model gateway command in producti 'USER:\nReply with exactly OK.', ]) }) + +test('wikiLlmComplete defaults to infer-model transport and does not touch globalThis.fetch', async () => { + const homeDir = await createHomeRoot('transport-default') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + + let fetchCalled = false + globalThis.fetch = (async () => { + fetchCalled = true + return new Response('{}', { status: 200 }) + }) as typeof fetch + + let inferCalled = false + setWikiLlmCommandRunnerForTests(async () => { + inferCalled = true + return { + code: 0, + stdout: JSON.stringify({ ok: true, outputs: [{ text: 'from-infer' }] }), + stderr: '', + } + }) + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'from-infer') + assert.equal(inferCalled, true) + assert.equal(fetchCalled, false) +}) + +test('wikiLlmComplete uses gateway-fetch when WIKI_LLM_USE_GATEWAY=1', async () => { + const homeDir = await createHomeRoot('transport-env') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + gateway: { port: 19999, auth: { mode: 'token', token: 'env-token' } }, + }) + + process.env.WIKI_LLM_USE_GATEWAY = '1' + let fetchCalled = false + globalThis.fetch = (async () => { + fetchCalled = true + return new Response(JSON.stringify({ + choices: [{ message: { content: 'from-gateway' } }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'from-gateway') + assert.equal(fetchCalled, true) +}) + +test('setWikiLlmTransportForTests explicit override beats WIKI_LLM_USE_GATEWAY env', async () => { + const homeDir = await createHomeRoot('transport-override') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + + process.env.WIKI_LLM_USE_GATEWAY = '1' + setWikiLlmTransportForTests('infer-model') + + let inferCalled = false + setWikiLlmCommandRunnerForTests(async () => { + inferCalled = true + return { code: 0, stdout: JSON.stringify({ ok: true, outputs: [{ text: 'infer-wins' }] }), stderr: '' } + }) + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'infer-wins') + assert.equal(inferCalled, true) +}) diff --git a/packages/backend/src/services/wikiLlm.ts b/packages/backend/src/services/wikiLlm.ts index e17b191..034e308 100644 --- a/packages/backend/src/services/wikiLlm.ts +++ b/packages/backend/src/services/wikiLlm.ts @@ -29,6 +29,8 @@ export interface WikiLlmContext extends OpenclawProfileContext { profileSelection?: OpenclawProfileSelection } +export type WikiLlmTransport = 'infer-model' | 'gateway-fetch' + type GatewayChatCompletionResponse = { choices?: Array<{ message?: { @@ -48,8 +50,9 @@ type InferModelRunResponse = { const DEFAULT_GATEWAY_PORT = 18789 const DEFAULT_MAX_WIKI_LLM_TOKENS = 4096 const DEFAULT_WIKI_LLM_TIMEOUT_MS = 120_000 -const nativeFetch = globalThis.fetch + let wikiLlmCommandRunnerOverride: typeof execOpenclaw | null = null +let wikiLlmTransportOverride: WikiLlmTransport | null = null function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -138,20 +141,11 @@ function extractInferModelText(payload: InferModelRunResponse): string { return extractContent(outputs.map((item) => item?.text)).trim() } -function shouldUseMockedFetchTransport(): boolean { - if (useGatewayFetchForTests) return true - // Explicit opt-in environment variable takes precedence. - if (process.env.WIKI_LLM_USE_GATEWAY !== undefined) { - return process.env.WIKI_LLM_USE_GATEWAY === '1' - } - // Fall back to detecting whether globalThis.fetch has been replaced - // (e.g. by test mocks) by comparing against the native reference captured - // at module load time. This preserves the existing behaviour where tests - // that mock fetch automatically route through the gateway-fetch path. - // Production code that merely wraps fetch (APM agents, polyfills) should - // set WIKI_LLM_USE_GATEWAY=1 explicitly instead of relying on this - // identity check. - return globalThis.fetch !== nativeFetch +function resolveTransport(): WikiLlmTransport { + if (wikiLlmTransportOverride !== null) return wikiLlmTransportOverride + const envValue = process.env['WIKI_LLM_USE_GATEWAY'] + if (envValue === '1') return 'gateway-fetch' + return 'infer-model' } function getWikiLlmCommandRunner(): typeof execOpenclaw { @@ -274,10 +268,15 @@ export async function wikiLlmComplete( throw new Error(resolved.disabledReason || 'Wiki LLM is not enabled.') } - if (!wikiLlmCommandRunnerOverride && shouldUseMockedFetchTransport()) { - return wikiLlmCompleteViaGatewayFetch(resolved, messages, options) + // An explicit command-runner override (used by unit tests targeting the CLI + // transport) always takes precedence so the runner is actually exercised. + if (wikiLlmCommandRunnerOverride) { + return wikiLlmCompleteViaInferModel(resolved, messages) } + if (resolveTransport() === 'gateway-fetch') { + return wikiLlmCompleteViaGatewayFetch(resolved, messages, options) + } return wikiLlmCompleteViaInferModel(resolved, messages) } @@ -307,15 +306,24 @@ export async function wikiLlmCompleteStructured( } } +/** @internal */ export function setWikiLlmCommandRunnerForTests( runner: typeof execOpenclaw | null, ): void { wikiLlmCommandRunnerOverride = runner } -let useGatewayFetchForTests = false +/** @internal Force a specific transport for tests. Pass null to restore default resolution. */ +export function setWikiLlmTransportForTests(transport: WikiLlmTransport | null): void { + wikiLlmTransportOverride = transport +} -/** @internal Allow tests to force the gateway-fetch transport path. */ +/** + * @deprecated use setWikiLlmTransportForTests('gateway-fetch') instead. + * Kept temporarily so existing test files keep compiling during the + * wikiLlm transport refactor. + */ export function setWikiLlmUseGatewayFetchForTests(flag: boolean): void { - useGatewayFetchForTests = flag + setWikiLlmTransportForTests(flag ? 'gateway-fetch' : null) } + diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts index a886202..f395c51 100644 --- a/packages/backend/src/services/wikiService.ts +++ b/packages/backend/src/services/wikiService.ts @@ -292,6 +292,15 @@ function nowIso(): string { return new Date().toISOString() } +function logWikiLlmFailure(code: string, error: unknown, extra?: Record): void { + const message = error instanceof Error ? error.message : String(error) + // Surface LLM failures as a warning log so operators can triage gateway/auth + // misconfiguration in the field. Callers still propagate a stable warning + // code through the response payload; this adds visibility without changing + // the contract. + console.warn(`[wiki] ${code}: ${message}`, extra ?? {}) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -364,12 +373,11 @@ function stripMarkdownSection(body: string, sectionTitle: string): string { function sanitizeWikiBody(title: string, body: string): string { const withoutHeading = stripHeading(title, body) - const withoutGeneratedBlocks = withoutHeading - .replace(//g, '\n') - .replace(//g, '\n') - .replace(//g, '\n') + // Strip any HTML comment — the CLAWMASTER-GENERATED markers and any other + // comment block are all covered by the generic pattern below. + const withoutComments = withoutHeading.replace(//g, '\n') return ['Extracted Wiki Links', 'Sources'] - .reduce((content, sectionTitle) => stripMarkdownSection(content, sectionTitle), withoutGeneratedBlocks) + .reduce((content, sectionTitle) => stripMarkdownSection(content, sectionTitle), withoutComments) .replace(/\n{3,}/g, '\n\n') .trim() } @@ -1537,8 +1545,9 @@ export async function ingestWikiSource( context, ) derivedExtractionCompleted = true - } catch { + } catch (error) { warnings.push('wiki_llm_extract_failed') + logWikiLlmFailure('wiki_llm_extract_failed', error, { title }) } } } @@ -1630,9 +1639,10 @@ export async function ingestWikiSource( context, ) nextDerivedResults.push(result) - } catch { + } catch (error) { derivedUpsertFailed = true warnings.push(`wiki_derived_page_failed:${item.name}`) + logWikiLlmFailure('wiki_derived_page_failed', error, { name: item.name }) } } @@ -1766,8 +1776,9 @@ export async function queryWiki( { maxTokens: 1024, temperature: 0.3 }, context, ) - } catch { + } catch (error) { warnings.push('wiki_llm_query_fallback') + logWikiLlmFailure('wiki_llm_query_fallback', error, { query: trimmed }) } } @@ -2147,8 +2158,12 @@ async function detectContradictions( detail: `${left.title} vs ${right.title}: ${contradiction.explanation.trim()}`, }) } - } catch { + } catch (error) { warnings.push('wiki_llm_contradiction_check_failed') + logWikiLlmFailure('wiki_llm_contradiction_check_failed', error, { + left: left.id, + right: right.id, + }) break } } @@ -2431,7 +2446,8 @@ async function reviseStalePageWithLlm( await fs.writeFile(page.path, renderMarkdownWithFrontmatter(nextFrontmatter, nextBody), 'utf8') await syncPageManagedMemory(page.path, context) return { changed: true } - } catch { + } catch (error) { + logWikiLlmFailure('wiki_llm_deep_evolve_failed', error, { pageId: page.id }) return { changed: false, warning: `wiki_llm_deep_evolve_failed:${page.id}` } } } From 9293cd4b955543df5bd9ad066604acd6c350998e Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Tue, 12 May 2026 11:38:46 +0800 Subject: [PATCH 5/7] perf(wiki): hoist listWikiPages out of derived-page ingest loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingestWikiSource called listWikiPages(context) inside every iteration of the derived-page upsert loop — an O(N×M) filesystem scan per ingest. Fix: compute knownPages once before the loop and pass the snapshot to upsertDerivedPage. A mutable copy is kept and extended after each successful upsert so later suggestions in the same batch see freshly created pages without a re-scan. The duplicate listWikiPages on the skipped-fingerprint fast-path now reuses the same snapshot. Also truncate and sanitize contradiction check inputs to MAX_CONTRADICTION_PAGE_CHARS (3000 chars) per page — same sanitizer used for query synthesis — to cap per-pair token spend in a dense vault. A test asserts the user message delivered to the LLM is < 7500 chars even when each source page is 10 000 chars of body text. --- .../backend/src/services/wikiService.test.ts | 36 ++++++++++++++++ packages/backend/src/services/wikiService.ts | 42 ++++++++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/services/wikiService.test.ts b/packages/backend/src/services/wikiService.test.ts index cb12a36..c469d5c 100644 --- a/packages/backend/src/services/wikiService.test.ts +++ b/packages/backend/src/services/wikiService.test.ts @@ -747,6 +747,42 @@ test('lint flags orphan and missing linked pages, evolve records freshness', asy assert.deepEqual(related[created.page!.id], []) }) +test('contradiction detection sanitizes and truncates page content before sending to llm', async () => { + const context = await createContext('contradiction-truncate') + // Ingest without a model configured so LLM extraction is skipped during + // ingest itself; the large pages are still written to disk. LLM is only + // enabled afterwards for the lint contradiction check. + const longContent = 'X'.repeat(10_000) + await ingestWikiSource( + { title: 'Big Page A', content: `[[Big Page B]] ${longContent}`, sourcePath: '/a.md' }, + context, + ) + await ingestWikiSource( + { title: 'Big Page B', content: longContent, sourcePath: '/b.md' }, + context, + ) + + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmUseGatewayFetchForTests(true) + let receivedBodyLength = 0 + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { messages?: Array<{ content?: string }> } + const userMessage = body.messages?.find((m) => m.content?.includes('Page 1'))?.content ?? '' + receivedBodyLength = userMessage.length + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ contradictions: [] }) } }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + + await lintWiki(context) + assert.ok( + receivedBodyLength < 7_500, + `contradiction check sent ${receivedBodyLength} chars — expected < 7500 after truncation`, + ) +}) + test('lint emits contradiction issues when llm contradiction checks find a conflict', async () => { const context = await createContext('contradiction') await writeWikiConfig(context, { diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts index f395c51..4f010ff 100644 --- a/packages/backend/src/services/wikiService.ts +++ b/packages/backend/src/services/wikiService.ts @@ -286,6 +286,7 @@ const GENERATED_BLOCK_PREFIX = 'CLAWMASTER-GENERATED' const DERIVED_PAGE_CONFIDENCE_THRESHOLD = 0.72 const MAX_INGEST_LLM_CHARS = 12_000 const MAX_DEEP_EVOLVE_PAGES = 5 +const MAX_CONTRADICTION_PAGE_CHARS = 3_000 const MAX_CONTRADICTION_PAIRS = 10 function nowIso(): string { @@ -1517,7 +1518,7 @@ export async function ingestWikiSource( sourcePath: ingestInput.sourcePath, }) if (previous?.fingerprint === fingerprint && await pathExists(pagePath)) { - const page = (await listWikiPages(context)).find((item) => item.id === previous.pageId) + const page = knownPages.find((item) => item.id === previous.pageId) return { state: 'skipped', confirmationRequired: false, @@ -1624,6 +1625,10 @@ export async function ingestWikiSource( const nextDerivedResults: WikiDerivedResult[] = [] let derivedUpsertFailed = false + // Maintain a local snapshot of known pages and extend it after each + // successful derived upsert so later suggestions in the same batch see pages + // created earlier in the same ingest without a full re-scan. + const mutableKnownPages = [...knownPages] for (const item of uniqueSuggestions) { try { const result = await upsertDerivedPage( @@ -1635,10 +1640,35 @@ export async function ingestWikiSource( sourcePath: ingestInput.sourcePath, }, item, - await listWikiPages(context), + mutableKnownPages, context, ) nextDerivedResults.push(result) + if (result.created) { + mutableKnownPages.push({ + id: result.pageId, + title: item.existingTitle || item.name, + type: result.pageType, + path: derivedPagePath(paths, result.pageType, item.name), + relativePath: '', + snippet: '', + sourceCount: 0, + freshnessStatus: 'fresh', + freshnessScore: 1, + lifecycleState: 'just_ingested', + createdAt: nowIso(), + updatedAt: nowIso(), + evolvedAt: '', + evolveCheckedAt: '', + evolveChangedAt: '', + evolveChangeSummary: '', + evolveSource: '', + lastAccessedAt: '', + links: [], + backlinks: [], + memoryIds: result.memoryId ? [result.memoryId] : [], + }) + } } catch (error) { derivedUpsertFailed = true warnings.push(`wiki_derived_page_failed:${item.name}`) @@ -2128,17 +2158,19 @@ async function detectContradictions( try { const leftPage = await getWikiPage(left.id, context) const rightPage = await getWikiPage(right.id, context) + const leftContent = sanitizeContentForSynthesis(leftPage).slice(0, MAX_CONTRADICTION_PAGE_CHARS) + const rightContent = sanitizeContentForSynthesis(rightPage).slice(0, MAX_CONTRADICTION_PAGE_CHARS) const result = await wikiLlmCompleteStructured<{ contradictions?: Array<{ claim1?: string; claim2?: string; explanation?: string }> }>( buildWikiLlmMessages( - 'Compare two wiki pages and report factual contradictions only. Return an empty contradictions array when the pages are compatible.', + 'Compare two knowledge pages and report factual contradictions only. Return an empty contradictions array when the pages are compatible.', [ `Page 1: [[${leftPage.title}]]`, - leftPage.content, + leftContent, '', `Page 2: [[${rightPage.title}]]`, - rightPage.content, + rightContent, ].join('\n'), ), { From b579d926ef4fd4f8073a2c1f4cffdf0def74fba5 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Tue, 12 May 2026 11:43:10 +0800 Subject: [PATCH 6/7] chore(wiki): rename log helper, guard proof helper, document bridge install flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugins/memory-clawmaster-powermem/index.ts: rename buildAutoRecallLogForTest → formatAutoRecallLog. The function is called in production code; the ForTest suffix was misleading. Update the call site and the test import. - packages/backend/src/services/managedMemoryBridge.ts: expand the inline comment on dangerouslyForceUnsafeInstall to explain which specific check it bypasses (resetManagedMemory access + direct openclaw/plugin-sdk imports) and note what a narrower alternative would look like when OpenClaw adds one. - tests/ui/wiki-powermem-proof-helper.ts: add a fail-fast check before syncManagedMemoryBridge runs — if the resolved --home path is the real user home or any subdirectory of it, throw rather than risk writing into the live OpenClaw profile. --- .../backend/src/services/managedMemoryBridge.ts | 11 +++++++++-- plugins/memory-clawmaster-powermem/index.test.ts | 8 ++++---- plugins/memory-clawmaster-powermem/index.ts | 4 ++-- tests/ui/wiki-powermem-proof-helper.ts | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/services/managedMemoryBridge.ts b/packages/backend/src/services/managedMemoryBridge.ts index f5d245f..179b604 100644 --- a/packages/backend/src/services/managedMemoryBridge.ts +++ b/packages/backend/src/services/managedMemoryBridge.ts @@ -520,8 +520,15 @@ export async function syncManagedMemoryBridge( if (!installed || pathIssue) { await openclawPlugins.installOpenclawPluginFromPath(paths.runtimePluginPath, { link: true, - // This ClawMaster-managed bridge intentionally uses privileged plugin APIs - // and workspace import helpers that OpenClaw's generic installer flags. + // memory-clawmaster-powermem imports from openclaw/plugin-sdk directly and + // uses resetManagedMemory — a privileged API that OpenClaw's generic + // installer considers "unsafe" because it can irrecoverably clear a profile's + // managed memory store. As the ClawMaster-managed bridge this is intentional; + // ClawMaster owns the lifecycle of this plugin installation. + // + // If a future OpenClaw release adds a narrower exemption mechanism (e.g. a + // trusted-installer allowlist keyed by package name), switch to that instead + // of keeping this override. dangerouslyForceUnsafeInstall: true, }) } diff --git a/plugins/memory-clawmaster-powermem/index.test.ts b/plugins/memory-clawmaster-powermem/index.test.ts index 8bd2726..3da5ecc 100644 --- a/plugins/memory-clawmaster-powermem/index.test.ts +++ b/plugins/memory-clawmaster-powermem/index.test.ts @@ -6,7 +6,7 @@ import test from 'node:test' import { buildAutoRecallContextForTest, - buildAutoRecallLogForTest, + formatAutoRecallLog, buildWikiSourceMarkdownForTest, buildWikiLinkChoiceReplyForTest, defaultManagedEngineForTest, @@ -113,13 +113,13 @@ test('buildAutoRecallContextForTest injects wiki context only for wiki-relevant assert.doesNotMatch(ordinary.prependContext ?? '', //) }) -test('buildAutoRecallLogForTest summarizes wiki relevance and hit counts', () => { +test('formatAutoRecallLog summarizes wiki relevance and hit counts', () => { assert.equal( - buildAutoRecallLogForTest('what do we know about AI agents?', { wikiCount: 2, memoryCount: 1 }), + formatAutoRecallLog('what do we know about AI agents?', { wikiCount: 2, memoryCount: 1 }), 'memory-clawmaster-powermem: auto-recall wikiRelevant=true wikiHits=2 memoryHits=1 query="what do we know about AI agents?"', ) assert.equal( - buildAutoRecallLogForTest('what is 2 plus 2?', { wikiCount: 0, memoryCount: 0 }), + formatAutoRecallLog('what is 2 plus 2?', { wikiCount: 0, memoryCount: 0 }), 'memory-clawmaster-powermem: auto-recall wikiRelevant=false wikiHits=0 memoryHits=0 query="what is 2 plus 2?"', ) }) diff --git a/plugins/memory-clawmaster-powermem/index.ts b/plugins/memory-clawmaster-powermem/index.ts index 2ffce78..54d8189 100644 --- a/plugins/memory-clawmaster-powermem/index.ts +++ b/plugins/memory-clawmaster-powermem/index.ts @@ -316,7 +316,7 @@ function clipAutoRecallQuery(query: string, maxLength = 96): string { return `${normalized.slice(0, maxLength - 3)}...` } -export function buildAutoRecallLogForTest( +export function formatAutoRecallLog( query: string, summary: { wikiCount: number; memoryCount: number }, ): string { @@ -1480,7 +1480,7 @@ const plugin = { .filter((item) => (item.score ?? 0) >= cfg.recallScoreThreshold) const recallContext = buildAutoRecallContextForTest(query, results, cfg.recallLimit) - api.logger.info(buildAutoRecallLogForTest(query, recallContext)) + api.logger.info(formatAutoRecallLog(query, recallContext)) return { prependSystemContext: MEMORY_RECALL_GUIDANCE, ...(recallContext.prependContext diff --git a/tests/ui/wiki-powermem-proof-helper.ts b/tests/ui/wiki-powermem-proof-helper.ts index dc7b6ae..70469df 100644 --- a/tests/ui/wiki-powermem-proof-helper.ts +++ b/tests/ui/wiki-powermem-proof-helper.ts @@ -356,6 +356,21 @@ async function initProfile(options: CliOptions): Promise { ) } + // Fail fast if the resolved home is inside the real user's home directory. + // Running syncManagedMemoryBridge against the real home would write live data + // into the user's OpenClaw profile — the isolated root is the whole point. + const resolvedHomeNormalized = path.resolve(homeDir) + const realHomeNormalized = path.resolve(REAL_HOME) + if ( + resolvedHomeNormalized === realHomeNormalized || + resolvedHomeNormalized.startsWith(realHomeNormalized + path.sep) + ) { + throw new Error( + `Refusing to run bridge sync: resolved home "${resolvedHomeNormalized}" is inside the real user home "${realHomeNormalized}". ` + + 'Pass --root to an isolated temp directory outside your home.', + ) + } + process.env.HOME = homeDir let bridgeState = 'skipped' From 5b912074185797384289a5944cac771aaa0e5010 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Tue, 12 May 2026 12:14:53 +0800 Subject: [PATCH 7/7] fix(wiki): resolve three post-review follow-ups 1. Plumb managedMemoryContext.dataRootOverride into resolveWikiPaths so the shared vault-root resolver is exercised at runtime, not only in unit tests. Before this change, the Stage 2 dataRootOverride support in resolveWikiVaultLayout was dead code from the wikiService call path. Add a unit test in openclawVaultPaths.test.ts and an integration test in wikiService.test.ts asserting the vault root resolves correctly when only dataRootOverride (no vaultRootOverride) is set. 2. Add a multi-element readListFrontmatter round-trip test. The previous YAML-array tests only exercised single-element arrays. The new test ingests a source page where the LLM returns two suggestions and asserts that relatedPages persists as a two-element YAML inline array on disk while the in-memory representation stays pipe-joined. 3. Replace setWikiLlmUseGatewayFetchForTests with setWikiLlmTransportForTests('gateway-fetch') in the contradiction- truncation test and in afterEach, ahead of the deprecated shim's removal. --- .../backend/src/openclawVaultPaths.test.ts | 11 +++ .../backend/src/services/wikiService.test.ts | 74 ++++++++++++++++++- packages/backend/src/services/wikiService.ts | 1 + 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/openclawVaultPaths.test.ts b/packages/backend/src/openclawVaultPaths.test.ts index 23b161d..16c7595 100644 --- a/packages/backend/src/openclawVaultPaths.test.ts +++ b/packages/backend/src/openclawVaultPaths.test.ts @@ -208,6 +208,17 @@ test('ensureWikiVaultStructure is idempotent and does not overwrite existing con } }) +test('resolveWikiVaultLayout uses dataRootOverride when vaultRootOverride is absent', () => { + const layout = resolveWikiVaultLayout({ + env: emptyEnv(), + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(layout.vaultRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) + assert.equal(layout.pagesRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki', 'pages')) + assert.equal(layout.ingestStatePath, path.posix.join('/home/testuser/.openclaw-work', 'wiki', '.meta', 'ingest-state.json')) +}) + test('resolveWikiVaultRoot matches the plugin-side resolution for representative managed data roots (parity)', () => { const cases = [ { diff --git a/packages/backend/src/services/wikiService.test.ts b/packages/backend/src/services/wikiService.test.ts index c469d5c..dd480ac 100644 --- a/packages/backend/src/services/wikiService.test.ts +++ b/packages/backend/src/services/wikiService.test.ts @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import { closeManagedMemoryRuntimesForTests } from './managedMemory.js' -import { setWikiLlmUseGatewayFetchForTests } from './wikiLlm.js' +import { setWikiLlmTransportForTests, setWikiLlmUseGatewayFetchForTests } from './wikiLlm.js' import { assistWithWiki, classifyWikiQuestion, @@ -48,7 +48,7 @@ async function writeWikiConfig(context: WikiServiceContext, config: Record { await closeManagedMemoryRuntimesForTests() globalThis.fetch = originalFetch - setWikiLlmUseGatewayFetchForTests(false) + setWikiLlmTransportForTests(null) }) test('ensureWikiVault creates the expected wiki structure', async () => { @@ -63,6 +63,21 @@ test('ensureWikiVault creates the expected wiki structure', async () => { await assert.doesNotReject(fs.stat(paths.freshnessPath)) }) +test('resolveWikiPaths derives vault root from managedMemoryContext.dataRootOverride when vaultRootOverride is absent', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-wiki-datarootoverride-')) + const dataRoot = path.join(tempRoot, '.clawmaster', 'data', 'default') + const context: WikiServiceContext = { + homeDir: tempRoot, + managedMemoryContext: { + dataRootOverride: dataRoot, + profileSelection: { kind: 'default' }, + engineOverride: 'powermem-sqlite', + }, + } + const paths = resolveWikiPaths(context) + assert.equal(paths.vaultRoot, path.join(tempRoot, '.openclaw', 'wiki')) +}) + test('ingest creates a managed memory-backed markdown page and repeat ingest skips unchanged source', async () => { const context = await createContext('ingest') const first = await ingestWikiSource( @@ -284,6 +299,59 @@ test('llm ingest creates derived pages and removes outdated generated pages on r assert.ok(!pages.some((page) => page.id === 'entities-seekdb-runtime')) }) +test('multi-item relatedPages and generatedFromSourceIds persist as YAML arrays on disk', async () => { + const context = await createContext('multi-array-frontmatter') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + items: [ + { name: 'Entity Alpha', kind: 'entity', summary: 'First extracted entity.', confidence: 0.95 }, + { name: 'Concept Beta', kind: 'concept', summary: 'Second extracted concept.', confidence: 0.90 }, + ], + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch + + const result = await ingestWikiSource( + { title: 'Multi Source', content: 'Describes Entity Alpha and Concept Beta.', sourcePath: '/notes/multi.md' }, + context, + ) + + const sourcePage = await getWikiPage(result.page!.id, context) + const sourceRaw = await fs.readFile(sourcePage.path, 'utf8') + assert.match( + sourceRaw, + /relatedPages: \["Entity Alpha", "Concept Beta"\]/, + 'source page relatedPages should be a two-element YAML array on disk', + ) + assert.equal( + sourcePage.frontmatter.relatedPages, + 'Entity Alpha|Concept Beta', + 'in-memory relatedPages should be pipe-joined for backward-compat', + ) + + const derivedAlpha = await getWikiPage('entities-entity-alpha', context) + const alphaRaw = await fs.readFile(derivedAlpha.path, 'utf8') + assert.match( + alphaRaw, + /generatedFromSourceIds: \["sources-multi-source"\]/, + 'derived entity page generatedFromSourceIds should be a single-element YAML array on disk', + ) + + const derivedBeta = await getWikiPage('concepts-concept-beta', context) + const betaRaw = await fs.readFile(derivedBeta.path, 'utf8') + assert.match( + betaRaw, + /generatedFromSourceIds: \["sources-multi-source"\]/, + ) +}) + test('re-ingest preserves generated derived pages when extraction is unavailable and cleans them after recovery', async () => { const context = await createContext('derived-ingest-disabled') await writeWikiConfig(context, { @@ -765,7 +833,7 @@ test('contradiction detection sanitizes and truncates page content before sendin await writeWikiConfig(context, { agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, }) - setWikiLlmUseGatewayFetchForTests(true) + setWikiLlmTransportForTests('gateway-fetch') let receivedBodyLength = 0 globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? '{}')) as { messages?: Array<{ content?: string }> } diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts index 4f010ff..fb83c72 100644 --- a/packages/backend/src/services/wikiService.ts +++ b/packages/backend/src/services/wikiService.ts @@ -406,6 +406,7 @@ export function resolveWikiPaths(context: WikiServiceContext = {}): WikiPaths { platform: context.platform, profileSelection, vaultRootOverride: context.vaultRootOverride, + dataRootOverride: context.managedMemoryContext?.dataRootOverride, }) return { profileKey: getProfileKey(profileSelection),