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/backend/src/openclawVaultPaths.test.ts b/packages/backend/src/openclawVaultPaths.test.ts new file mode 100644 index 0000000..16c7595 --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.test.ts @@ -0,0 +1,260 @@ +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('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 = [ + { + 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..fde0492 --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.ts @@ -0,0 +1,224 @@ +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\`: 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 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 + +## 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/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/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.test.ts b/packages/backend/src/services/wikiService.test.ts index b44e512..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( @@ -254,6 +269,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', @@ -267,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, { @@ -730,6 +815,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' } } }, + }) + 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 }> } + 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 10dcb98..fb83c72 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,61 +281,27 @@ 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_PAGE_CHARS = 3_000 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() } +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) } @@ -373,25 +342,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 [ @@ -424,12 +374,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() } @@ -450,45 +399,31 @@ 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, + dataRootOverride: context.managedMemoryContext?.dataRootOverride, + }) 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 +442,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 } @@ -681,16 +604,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) @@ -704,25 +672,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[] { @@ -926,6 +911,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) { @@ -938,7 +929,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) @@ -974,7 +965,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] : [], } @@ -993,6 +984,7 @@ function renderWikiPage(input: { fingerprint: string createdAt: string updatedAt: string + relatedPages?: string[] }): string { const fm = renderFrontmatter({ id: input.id, @@ -1009,6 +1001,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, }) @@ -1083,7 +1076,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', }, @@ -1199,8 +1192,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)), @@ -1268,7 +1261,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 @@ -1288,7 +1281,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') @@ -1526,7 +1519,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, @@ -1554,23 +1547,22 @@ export async function ingestWikiSource( context, ) derivedExtractionCompleted = true - } catch { + } catch (error) { warnings.push('wiki_llm_extract_failed') + logWikiLlmFailure('wiki_llm_extract_failed', error, { title }) } } } 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( @@ -1627,12 +1619,17 @@ export async function ingestWikiSource( fingerprint, createdAt: existingPage?.createdAt || previous?.updatedAt || now, updatedAt: now, + relatedPages: relatedPageTitles, }), 'utf8', ) 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( @@ -1644,13 +1641,39 @@ export async function ingestWikiSource( sourcePath: ingestInput.sourcePath, }, item, - await listWikiPages(context), + mutableKnownPages, context, ) nextDerivedResults.push(result) - } catch { + 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}`) + logWikiLlmFailure('wiki_derived_page_failed', error, { name: item.name }) } } @@ -1784,8 +1807,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 }) } } @@ -2135,17 +2159,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'), ), { @@ -2165,8 +2191,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 } } @@ -2449,7 +2479,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}` } } } @@ -2458,7 +2489,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[] = [] @@ -2469,7 +2500,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) } } 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()) 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 de3424e..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 { @@ -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') } @@ -1478,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'