From 300a67660acbe54327bebb93b916b3fddd6663ab Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 27 Apr 2026 18:56:57 +0800 Subject: [PATCH 01/27] feat: show latest session cost on Observe (#102) * feat: show latest session cost on observe * ci: stabilize wizard skip smoke * fix: address observe session review * ci: relax wizard gateway skip smoke --- .github/workflows/test.yml | 38 +-- packages/web/src/locales/main/en.ts | 8 + packages/web/src/locales/main/ja.ts | 8 + packages/web/src/locales/main/zh.ts | 8 + .../web/src/modules/observe/ObservePage.tsx | 212 ++++++++++++++- .../observe/__tests__/ObservePage.test.tsx | 242 ++++++++++++++++++ .../adapters/__tests__/sessions.test.ts | 34 +++ packages/web/src/shared/adapters/sessions.ts | 13 +- 8 files changed, 525 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e1fc733..3aff1156 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -439,31 +439,21 @@ jobs: .catch(() => page.waitForSelector('text=Configure LLM Provider', { timeout: 5000 })); const skipLink = page.locator('text=跳过剩余步骤').or(page.locator('text=Skip')); await skipLink.first().click(); - const skipGatewayStep = page.locator('text=网关').or(page.locator('text=Gateway')); - await skipGatewayStep.first().waitFor({ timeout: 5000 }); - const startGatewayBtn = page.locator('text=启动网关').or(page.locator('text=Start Gateway')); - const checkGatewayBtn = page.locator('text=重新检查网关').or(page.locator('text=Check Again')); - const skipEnterBtn = page.locator('text=进入管理大师').or(page.locator('text=Enter ClawMaster')); + const skipGatewayStep = page.getByRole('heading', { name: /^(网关|Gateway|ゲートウェイ)$/ }); + await skipGatewayStep.waitFor({ timeout: 5000 }); // Skip only needs to prove the wizard lands on the mandatory gateway step - // and offers at least one valid next action from there. - let skipNextAction = await skipEnterBtn.first().isVisible().catch(() => false); - if (!skipNextAction) { - const canStartGateway = await startGatewayBtn.first().isVisible().catch(() => false); - if (canStartGateway) { - await startGatewayBtn.first().click(); - } else { - const canCheckGateway = await checkGatewayBtn.first().isVisible().catch(() => false); - if (canCheckGateway) { - await checkGatewayBtn.first().click(); - } - } - await page.waitForTimeout(2000); - const enterVisible = await skipEnterBtn.first().isVisible().catch(() => false); - const startVisible = await startGatewayBtn.first().isVisible().catch(() => false); - const checkVisible = await checkGatewayBtn.first().isVisible().catch(() => false); - skipNextAction = enterVisible || startVisible || checkVisible; - } - if (!skipNextAction) bail('Skip flow reached gateway step but exposed no valid next action'); + // instead of the optional channel steps. The gateway status check is + // asynchronous, so button visibility is intentionally not asserted here. + const gatewayGate = page.locator('text=正在检查网关状态') + .or(page.locator('text=Checking gateway status')) + .or(page.locator('text=ゲートウェイ状態を確認中')) + .or(page.locator('text=请先启动网关')) + .or(page.locator('text=Start the gateway')) + .or(page.locator('text=WebUI、チャネル')) + .or(page.locator('text=网关已运行')) + .or(page.locator('text=Gateway is running')) + .or(page.locator('text=ゲートウェイは稼働中')); + await gatewayGate.first().waitFor({ timeout: 5000 }); ok('Skip flow reached mandatory gateway step'); console.log(`\nAll ${step} wizard E2E checks passed`); diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index 5bb965f1..143956a8 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -1350,6 +1350,7 @@ export default { "plugins.disablePlugin": "Disable {{name}}", "plugins.uninstallPlugin": "Uninstall {{name}}", "common.unknownError": "Unknown error", + "observe.activeSession": "Active", "observe.agentLabel": "Agent", "observe.bootstrapAgain": "Re-bootstrap", "observe.bootstrapAuto": "Auto-bootstrap ClawProbe", @@ -1358,16 +1359,21 @@ export default { "observe.bootstrapStart": "Bootstrap ClawProbe", "observe.bootstrapWorking": "Bootstrapping...", "observe.contextUsage": "Context Usage", + "observe.contextWindow": "Context window", + "observe.costUnavailable": "Unavailable", "observe.daemonHint": "Run: clawprobe start", "observe.daemonLabel": "ClawProbe Daemon", "observe.dailyAvg": "Daily Avg", "observe.errorLi1": "Ensure clawprobe is installed (npm i -g clawprobe)", "observe.errorLi2": "Run clawprobe start to begin monitoring", "observe.errorTitle": "Cannot load observability data", + "observe.inputOutputTokens": "Input / Output", "observe.installAction": "Install and start ClawProbe", "observe.installBody": "Observability needs ClawProbe. Install it now from this flow or use the setup wizard later.", "observe.installTitle": "ClawProbe is not installed", "observe.installWorking": "Installing ClawProbe...", + "observe.lastActive": "Last active", + "observe.latestSession": "Latest Session", "observe.loadFailed": "Load failed", "observe.loading": "Loading observability data...", "observe.monthlyEst": "Monthly Est", @@ -1378,9 +1384,11 @@ export default { "observe.retry": "Retry", "observe.running": "Running", "observe.sectionSession": "Session & Context", + "observe.sessionCost": "Session Cost", "observe.sessionPrefix": "Session", "observe.subtitle": "Token usage, API costs, and context health", "observe.todayCostLabel": "Today", + "observe.tokens": "tokens", "observe.total": "Total", "observe.unpricedWarning": "Some models have no pricing data", "observe.periodDay": "Day", diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index fc2f6836..3bee87de 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -1350,6 +1350,7 @@ export default { "plugins.disablePlugin": "{{name}} を無効化", "plugins.uninstallPlugin": "{{name}} をアンインストール", "common.unknownError": "不明なエラー", + "observe.activeSession": "アクティブ", "observe.agentLabel": "エージェント", "observe.bootstrapAgain": "再ブートストラップ", "observe.bootstrapAuto": "ClawProbeを自動ブートストラップ", @@ -1358,16 +1359,21 @@ export default { "observe.bootstrapStart": "ClawProbeをブートストラップ", "observe.bootstrapWorking": "ブートストラップ中...", "observe.contextUsage": "コンテキスト使用量", + "observe.contextWindow": "コンテキストウィンドウ", + "observe.costUnavailable": "利用不可", "observe.daemonHint": "実行: clawprobe start", "observe.daemonLabel": "ClawProbeデーモン", "observe.dailyAvg": "日平均", "observe.errorLi1": "clawprobeがインストール済みか確認 (npm i -g clawprobe)", "observe.errorLi2": "clawprobe start でモニタリングを開始", "observe.errorTitle": "可観測データを読み込めません", + "observe.inputOutputTokens": "入力 / 出力", "observe.installAction": "ClawProbeをインストールして起動", "observe.installBody": "可観測機能には ClawProbe が必要です。この画面から今すぐ導入するか、後でセットアップウィザードから実行できます。", "observe.installTitle": "ClawProbe が未インストールです", "observe.installWorking": "ClawProbe をインストール中...", + "observe.lastActive": "最終アクティブ", + "observe.latestSession": "最新セッション", "observe.loadFailed": "読み込み失敗", "observe.loading": "可観測データ読み込み中...", "observe.monthlyEst": "月間推定", @@ -1378,9 +1384,11 @@ export default { "observe.retry": "再試行", "observe.running": "実行中", "observe.sectionSession": "セッションとコンテキスト", + "observe.sessionCost": "セッションコスト", "observe.sessionPrefix": "セッション", "observe.subtitle": "トークン使用量、APIコスト、コンテキストヘルス", "observe.todayCostLabel": "本日", + "observe.tokens": "トークン", "observe.total": "合計", "observe.unpricedWarning": "一部モデルに価格データがありません", "observe.periodDay": "日", diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index f10c9681..64a78a73 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -1350,6 +1350,7 @@ export default { "plugins.disablePlugin": "禁用 {{name}}", "plugins.uninstallPlugin": "卸载 {{name}}", "common.unknownError": "未知错误", + "observe.activeSession": "活跃", "observe.agentLabel": "智能体", "observe.bootstrapAgain": "重新引导", "observe.bootstrapAuto": "自动引导 ClawProbe", @@ -1358,16 +1359,21 @@ export default { "observe.bootstrapStart": "引导 ClawProbe", "observe.bootstrapWorking": "引导中...", "observe.contextUsage": "上下文使用", + "observe.contextWindow": "上下文窗口", + "observe.costUnavailable": "不可用", "observe.daemonHint": "运行: clawprobe start", "observe.daemonLabel": "ClawProbe 守护进程", "observe.dailyAvg": "日均", "observe.errorLi1": "确保 clawprobe 已安装 (npm i -g clawprobe)", "observe.errorLi2": "运行 clawprobe start 开始监控", "observe.errorTitle": "无法加载可观测数据", + "observe.inputOutputTokens": "输入 / 输出", "observe.installAction": "安装并启动 ClawProbe", "observe.installBody": "可观测功能依赖 ClawProbe。你可以在这里立即安装,也可以稍后通过安装向导处理。", "observe.installTitle": "ClawProbe 尚未安装", "observe.installWorking": "正在安装 ClawProbe...", + "observe.lastActive": "最近活跃", + "observe.latestSession": "最新会话", "observe.loadFailed": "加载失败", "observe.loading": "加载可观测数据中...", "observe.monthlyEst": "月估", @@ -1378,9 +1384,11 @@ export default { "observe.retry": "重试", "observe.running": "运行中", "observe.sectionSession": "会话与上下文", + "observe.sessionCost": "会话费用", "observe.sessionPrefix": "会话", "observe.subtitle": "Token 用量、API 费用和上下文健康", "observe.todayCostLabel": "今日", + "observe.tokens": "Token", "observe.total": "总计", "observe.unpricedWarning": "部分模型无定价数据", "observe.periodDay": "日", diff --git a/packages/web/src/modules/observe/ObservePage.tsx b/packages/web/src/modules/observe/ObservePage.tsx index ea819a46..1f5ec50a 100644 --- a/packages/web/src/modules/observe/ObservePage.tsx +++ b/packages/web/src/modules/observe/ObservePage.tsx @@ -5,6 +5,12 @@ import { platformResults } from '@/adapters' import { getSetupAdapter } from '@/modules/setup/adapters' import { setSkillEnabledResult } from '@/shared/adapters/clawhub' import { allSuccess2 } from '@/shared/adapters/resultHelpers' +import { + getSessionDetail, + getSessions, + type SessionDetail, + type SessionInfo, +} from '@/shared/adapters/sessions' import type { AdapterResult } from '@/shared/adapters/types' import { fail, ok } from '@/shared/adapters/types' import { InstallTask } from '@/shared/components/InstallTask' @@ -19,16 +25,141 @@ type ObserveBundle = { status: ClawprobeStatusJson cost: ClawprobeCostJson config: ClawprobeConfigJson | null + latestSession: SessionInfo | null + latestSessionDetail: SessionDetail | null } +type ObserveSessionSnapshot = { + key: string + model: string | null + provider: string | null + inputTokens: number + outputTokens: number + usedContextTokens: number + contextTokens: number + utilizationPct: number + estimatedUsd: number | null + updatedAtMs: number + isActive: boolean +} + +const ACTIVE_SESSION_WINDOW_MS = 5 * 60 * 1000 + function severityClass(sev: string): string { if (sev === 'critical') return 'border-red-500/40 bg-red-500/5 text-red-900 dark:text-red-100' if (sev === 'warning') return 'border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100' return 'border-blue-500/35 bg-blue-500/5 text-blue-950 dark:text-blue-100' } +function normalizeTimestampMs(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0 + return value < 1_000_000_000_000 ? value * 1000 : value +} + +function sessionUpdatedAtMs(session: SessionInfo, now = Date.now()): number { + const explicit = normalizeTimestampMs(session.updatedAt) + if (explicit > 0) return explicit + if (session.ageMs > 0) return now - session.ageMs + return 0 +} + +function sessionTieBreakerKey(session: SessionInfo): string { + return session.key || session.sessionId || session.agentId +} + +function selectLatestSession(sessions: SessionInfo[]): SessionInfo | null { + const now = Date.now() + return sessions.reduce((latest, session) => { + if (!latest) return session + const sessionTime = sessionUpdatedAtMs(session, now) + const latestTime = sessionUpdatedAtMs(latest, now) + if (sessionTime !== latestTime) return sessionTime > latestTime ? session : latest + return sessionTieBreakerKey(session).localeCompare(sessionTieBreakerKey(latest)) > 0 + ? session + : latest + }, null) +} + +function tokenPercentage(total: number, context: number): number { + if (context <= 0) return 0 + return Math.min(Math.round((total / context) * 100), 100) +} + +function firstPositiveNumber(...values: Array): number { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value + } + return 0 +} + +function buildSessionSnapshot( + status: ClawprobeStatusJson, + latestSession: SessionInfo | null, + latestSessionDetail: SessionDetail | null +): ObserveSessionSnapshot | null { + if (latestSession) { + const key = latestSession.key || latestSession.sessionId + const detail = latestSessionDetail + const contextTokens = firstPositiveNumber( + detail?.windowSize, + latestSession.contextTokens, + status.sessionKey === key ? status.windowSize : 0 + ) + const usedContextTokens = firstPositiveNumber( + detail?.contextTokens, + status.sessionKey === key ? status.sessionTokens : 0 + ) + return { + key, + model: detail?.model || latestSession.model || (status.sessionKey === key ? status.model : null), + provider: detail?.provider || latestSession.modelProvider || (status.sessionKey === key ? status.provider : null), + inputTokens: detail?.inputTokens ?? latestSession.inputTokens, + outputTokens: detail?.outputTokens ?? latestSession.outputTokens, + usedContextTokens, + contextTokens, + utilizationPct: tokenPercentage(usedContextTokens, contextTokens), + estimatedUsd: detail ? detail.estimatedUsd : null, + updatedAtMs: sessionUpdatedAtMs(latestSession), + isActive: status.sessionKey === key || (latestSession.ageMs > 0 && latestSession.ageMs < ACTIVE_SESSION_WINDOW_MS), + } + } + + if (!status.sessionKey) return null + const detail = latestSessionDetail + const contextTokens = firstPositiveNumber(detail?.windowSize, status.windowSize) + const usedContextTokens = firstPositiveNumber(detail?.contextTokens, status.sessionTokens) + return { + key: status.sessionKey, + model: detail?.model || status.model, + provider: detail?.provider || status.provider, + inputTokens: detail?.inputTokens ?? status.inputTokens, + outputTokens: detail?.outputTokens ?? status.outputTokens, + usedContextTokens, + contextTokens, + utilizationPct: tokenPercentage(usedContextTokens, contextTokens) || status.utilizationPct, + estimatedUsd: detail ? detail.estimatedUsd : null, + updatedAtMs: normalizeTimestampMs(detail?.lastActiveAt ?? status.lastActiveAt), + isActive: status.isActive, + } +} + +function formatDateTime(ms: number, language: string): string { + if (ms <= 0) return '-' + return new Date(ms).toLocaleString(language || undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatContextTokenValue(value: number): string { + return value > 0 ? value.toLocaleString() : '-' +} + export default function ObservePage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const navigate = useNavigate() const [costPeriod, setCostPeriod] = useState<'day' | 'week' | 'month' | 'all'>('week') const [bootstrapBusy, setBootstrapBusy] = useState(false) @@ -60,10 +191,28 @@ export default function ObservePage() { if (!core.success) { return fail(core.error ?? t('observe.loadFailed')) } + let latestSession: SessionInfo | null = null + let latestSessionDetail: SessionDetail | null = null + const status = core.data!.a + if (!status.installRequired) { + const sessionsResult = await getSessions() + if (sessionsResult.success && sessionsResult.data) { + latestSession = selectLatestSession(sessionsResult.data.sessions) + } + const detailKey = latestSession?.key || latestSession?.sessionId || status.sessionKey + if (detailKey) { + const detailResult = await getSessionDetail(detailKey, { agentId: latestSession?.agentId }) + if (detailResult.success && detailResult.data) { + latestSessionDetail = detailResult.data + } + } + } return ok({ - status: core.data!.a, + status, cost: core.data!.b, config: cf.success && cf.data ? cf.data : null, + latestSession, + latestSessionDetail, }) }, [costPeriod, t]) @@ -194,6 +343,7 @@ export default function ObservePage() { const { status, cost, config } = data const maxDailyUsd = Math.max(...cost.daily.map((d) => d.usd), 0.01) + const sessionSnapshot = buildSessionSnapshot(status, data.latestSession, data.latestSessionDetail) return (
@@ -301,31 +451,69 @@ export default function ObservePage() {
-
+
- {t('observe.contextUsage')} - {status.model && ( +
+ {t('observe.latestSession')} + {sessionSnapshot?.isActive ? ( + + {t('observe.activeSession')} + + ) : null} +
+ {sessionSnapshot?.model && ( - {status.model} + {[sessionSnapshot.provider, sessionSnapshot.model].filter(Boolean).join(' / ')} )}
- {status.sessionKey ? ( + {sessionSnapshot ? ( <> +
+
+
+ {t('observe.sessionCost')} +
+
+ {sessionSnapshot.estimatedUsd === null + ? t('observe.costUnavailable') + : `$${sessionSnapshot.estimatedUsd.toFixed(4)}`} +
+
+
+
+ {t('observe.inputOutputTokens')} +
+
+ {sessionSnapshot.inputTokens.toLocaleString()} / {sessionSnapshot.outputTokens.toLocaleString()} +
+
+
+
+ {t('observe.lastActive')} +
+
{formatDateTime(sessionSnapshot.updatedAtMs, i18n.language)}
+
+
+ +
+ {t('observe.contextUsage')} + {sessionSnapshot.utilizationPct}% +
- {status.sessionTokens.toLocaleString()} / {status.windowSize.toLocaleString()} tokens + {formatContextTokenValue(sessionSnapshot.usedContextTokens)} / {formatContextTokenValue(sessionSnapshot.contextTokens)} {t('observe.tokens')} - {status.utilizationPct}% + {t('observe.contextWindow')}
-

- {t('observe.sessionPrefix')} {status.sessionKey} +

+ {t('observe.sessionPrefix')} {sessionSnapshot.key}

) : ( diff --git a/packages/web/src/modules/observe/__tests__/ObservePage.test.tsx b/packages/web/src/modules/observe/__tests__/ObservePage.test.tsx index a828eac1..a4fb6b39 100644 --- a/packages/web/src/modules/observe/__tests__/ObservePage.test.tsx +++ b/packages/web/src/modules/observe/__tests__/ObservePage.test.tsx @@ -11,6 +11,8 @@ const mockClawprobeBootstrap = vi.fn() const mockInstallSkill = vi.fn() const mockSetSkillEnabled = vi.fn() const mockInstallCapabilities = vi.fn() +const mockGetSessions = vi.fn() +const mockGetSessionDetail = vi.fn() vi.mock('@/adapters', () => ({ platformResults: { @@ -32,6 +34,11 @@ vi.mock('@/shared/adapters/clawhub', () => ({ setSkillEnabledResult: (...args: any[]) => mockSetSkillEnabled(...args), })) +vi.mock('@/shared/adapters/sessions', () => ({ + getSessions: (...args: any[]) => mockGetSessions(...args), + getSessionDetail: (...args: any[]) => mockGetSessionDetail(...args), +})) + const fallbackStatus = { agent: 'OpenClaw', daemonRunning: false, @@ -95,6 +102,18 @@ describe('ObservePage', () => { mockInstallSkill.mockResolvedValue({ success: true, data: undefined }) mockSetSkillEnabled.mockResolvedValue({ success: true, data: undefined }) mockInstallCapabilities.mockResolvedValue(undefined) + mockGetSessions.mockResolvedValue({ + success: true, + data: { + path: '/tmp/.openclaw/workspace/.openclaw/sessions', + count: 0, + sessions: [], + }, + }) + mockGetSessionDetail.mockResolvedValue({ + success: false, + error: 'session not found', + }) }) it('renders the missing-clawprobe zero state instead of an error screen', async () => { @@ -218,4 +237,227 @@ describe('ObservePage', () => { expect(await screen.findByText('Install failed: write failed')).toBeInTheDocument() }) + + it('shows the latest session context usage and estimated cost', async () => { + mockClawprobeStatus.mockResolvedValueOnce({ + success: true, + data: { + ...fallbackStatus, + daemonRunning: true, + installRequired: false, + sessionKey: null, + }, + }) + mockGetSessions.mockResolvedValueOnce({ + success: true, + data: { + path: '/tmp/.openclaw/workspace/.openclaw/sessions', + count: 2, + sessions: [ + { + key: 'agent:main:older', + sessionId: 'older', + agentId: 'main', + model: 'gpt-4.1-mini', + modelProvider: 'openai', + kind: 'direct', + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + contextTokens: 20000, + updatedAt: 1774800000000, + ageMs: 120000, + }, + { + key: 'agent:main:latest', + sessionId: 'latest', + agentId: 'main', + model: 'gpt-4.1', + modelProvider: 'openai', + kind: 'direct', + inputTokens: 4300, + outputTokens: 700, + totalTokens: 5000, + contextTokens: 20000, + updatedAt: 1774900000000, + ageMs: 30000, + }, + ], + }, + }) + mockGetSessionDetail.mockResolvedValueOnce({ + success: true, + data: { + sessionKey: 'agent:main:latest', + model: 'gpt-4.1', + provider: 'openai', + inputTokens: 4300, + outputTokens: 700, + totalTokens: 25000, + contextTokens: 5000, + windowSize: 20000, + estimatedUsd: 0.042, + startedAt: 1774890000, + lastActiveAt: 1774900000, + durationMin: 12, + compactionCount: 0, + turns: [], + }, + }) + + render( + + + , + ) + + expect(await screen.findByText('Latest Session')).toBeInTheDocument() + expect(screen.getByText('$0.0420')).toBeInTheDocument() + expect(screen.getByText('5,000 / 20,000 tokens')).toBeInTheDocument() + expect(screen.getByTitle('agent:main:latest')).toBeInTheDocument() + expect(mockGetSessionDetail).toHaveBeenCalledWith('agent:main:latest', { agentId: 'main' }) + }) + + it('falls back to session list fields when latest session detail fails', async () => { + mockClawprobeStatus.mockResolvedValueOnce({ + success: true, + data: { + ...fallbackStatus, + daemonRunning: true, + installRequired: false, + sessionKey: null, + }, + }) + mockGetSessions.mockResolvedValueOnce({ + success: true, + data: { + path: '/tmp/.openclaw/workspace/.openclaw/sessions', + count: 1, + sessions: [ + { + key: 'agent:main:detail-missing', + sessionId: 'detail-missing', + agentId: 'main', + model: 'gpt-4.1-mini', + modelProvider: 'openai', + kind: 'direct', + inputTokens: 1200, + outputTokens: 300, + totalTokens: 78000, + contextTokens: 20000, + updatedAt: 1774900000000, + ageMs: 30000, + }, + ], + }, + }) + mockGetSessionDetail.mockResolvedValueOnce({ + success: false, + error: 'session not found', + }) + + render( + + + , + ) + + expect(await screen.findByText('Latest Session')).toBeInTheDocument() + expect(screen.getByText('Unavailable')).toBeInTheDocument() + expect(screen.getByText('1,200 / 300')).toBeInTheDocument() + expect(screen.getByText('- / 20,000 tokens')).toBeInTheDocument() + expect(screen.queryByText('78,000 / 20,000 tokens')).not.toBeInTheDocument() + }) + + it('passes the latest session agent when fetching details from a multi-agent session list', async () => { + mockClawprobeStatus.mockResolvedValueOnce({ + success: true, + data: { + ...fallbackStatus, + daemonRunning: true, + installRequired: false, + sessionKey: null, + }, + }) + mockGetSessions.mockResolvedValueOnce({ + success: true, + data: { + path: '/tmp/.openclaw/workspace/.openclaw/sessions', + count: 1, + sessions: [ + { + key: 'agent:review:latest', + sessionId: 'latest', + agentId: 'review', + model: 'gpt-4.1', + modelProvider: 'openai', + kind: 'direct', + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + contextTokens: 64000, + updatedAt: 1774900000000, + ageMs: 30000, + }, + ], + }, + }) + + render( + + + , + ) + + await screen.findByText('Latest Session') + expect(mockGetSessionDetail).toHaveBeenCalledWith('agent:review:latest', { agentId: 'review' }) + }) + + it('uses the active status session key for cost detail when the session list is empty', async () => { + mockClawprobeStatus.mockResolvedValueOnce({ + success: true, + data: { + ...fallbackStatus, + daemonRunning: true, + installRequired: false, + sessionKey: 'agent:main:active', + model: 'gpt-4.1-mini', + provider: 'openai', + sessionTokens: 2500, + windowSize: 10000, + utilizationPct: 25, + inputTokens: 2100, + outputTokens: 400, + isActive: true, + }, + }) + mockGetSessionDetail.mockResolvedValueOnce({ + success: true, + data: { + sessionKey: 'agent:main:active', + model: 'gpt-4.1-mini', + provider: 'openai', + inputTokens: 2100, + outputTokens: 400, + totalTokens: 2500, + estimatedUsd: 0.0187, + startedAt: 1774890000, + lastActiveAt: 1774900000, + durationMin: 5, + compactionCount: 0, + turns: [], + }, + }) + + render( + + + , + ) + + expect(await screen.findByText('Latest Session')).toBeInTheDocument() + expect(screen.getByText('$0.0187')).toBeInTheDocument() + expect(screen.getByText('2,500 / 10,000 tokens')).toBeInTheDocument() + expect(mockGetSessionDetail).toHaveBeenCalledWith('agent:main:active', { agentId: undefined }) + }) }) diff --git a/packages/web/src/shared/adapters/__tests__/sessions.test.ts b/packages/web/src/shared/adapters/__tests__/sessions.test.ts index 4b7265a0..8be72bc0 100644 --- a/packages/web/src/shared/adapters/__tests__/sessions.test.ts +++ b/packages/web/src/shared/adapters/__tests__/sessions.test.ts @@ -132,6 +132,40 @@ describe('sessions adapter', () => { expect(result.data!.durationMin).toBe(172) }) + it('passes agent id when loading a session from a non-default agent', async () => { + const { execCommand } = await import('../platform') + await mockExec(JSON.stringify({ sessionKey: 'agent:review:latest' })) + + const result = await getSessionDetail('agent:review:latest', { agentId: 'review' }) + + expect(result.success).toBe(true) + expect(execCommand).toHaveBeenCalledWith('clawprobe', [ + 'session', + 'agent:review:latest', + '--json', + '--agent', + 'review', + ]) + }) + + it('parses current context tokens separately from cumulative billed tokens', async () => { + await mockExec(JSON.stringify({ + sessionKey: 'agent:main:main', + inputTokens: 150000, + outputTokens: 20000, + totalTokens: 170000, + contextTokens: 32000, + windowSize: 128000, + })) + + const result = await getSessionDetail('agent:main:main') + + expect(result.success).toBe(true) + expect(result.data!.totalTokens).toBe(170000) + expect(result.data!.contextTokens).toBe(32000) + expect(result.data!.windowSize).toBe(128000) + }) + it('returns error when session not found', async () => { await mockExecFail('session not found') const result = await getSessionDetail('nonexistent') diff --git a/packages/web/src/shared/adapters/sessions.ts b/packages/web/src/shared/adapters/sessions.ts index ba8a50fa..b7347526 100644 --- a/packages/web/src/shared/adapters/sessions.ts +++ b/packages/web/src/shared/adapters/sessions.ts @@ -77,6 +77,8 @@ export interface SessionDetail { inputTokens: number outputTokens: number totalTokens: number + contextTokens: number + windowSize: number estimatedUsd: number startedAt: number lastActiveAt: number @@ -85,9 +87,14 @@ export interface SessionDetail { turns: TurnInfo[] } -export function getSessionDetail(key: string): Promise> { +export function getSessionDetail( + key: string, + options: { agentId?: string } = {} +): Promise> { return wrapAsync(async () => { - const raw = await execCommand('clawprobe', ['session', key, '--json']) + const args = ['session', key, '--json'] + if (options.agentId?.trim()) args.push('--agent', options.agentId.trim()) + const raw = await execCommand('clawprobe', args) const data = JSON.parse(raw) return { sessionKey: data.sessionKey ?? key, @@ -96,6 +103,8 @@ export function getSessionDetail(key: string): Promise Date: Mon, 27 Apr 2026 19:49:41 +0800 Subject: [PATCH 02/27] Add GLM provider support and integration guidance --- CONTRIBUTING.md | 57 +++++++++++++- .../services/providerCatalogService.test.ts | 37 +++++++++ .../src/services/providerCatalogService.ts | 3 + .../models/__tests__/ModelsPage.test.tsx | 76 +++++++++++++++++++ .../setup/__tests__/SetupWizard.test.tsx | 38 ++++++++++ .../setup/__tests__/realAdapter.test.ts | 33 ++++++++ packages/web/src/modules/setup/types.ts | 28 +++++++ .../web/src/shared/providerCatalog.test.ts | 36 +++++++++ packages/web/src/shared/providerCatalog.ts | 7 ++ 9 files changed, 312 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7af6485e..611af198 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,12 +28,12 @@ For desktop (Tauri) development, see the Rust/Tauri section in `CLAUDE.md`. ## Branch and PR Workflow 1. **Fork** the repository and clone your fork. -2. Create a **feature branch** from `main`: +2. Create a **feature branch** from `develop`: ```bash - git checkout -b feat/my-feature main + git checkout -b feat/my-feature develop ``` 3. Make your changes, commit with conventional messages (see below), and push. -4. Open a **Pull Request** against `main` in the upstream repo. +4. Open a **Pull Request** against `develop` in the upstream repo. 5. Fill in the PR template. Link related issues with `Closes #123`. Keep PRs focused -- one logical change per PR. @@ -46,6 +46,57 @@ Keep PRs focused -- one logical change per PR. - New features should be built as **capability modules** in `packages/web/src/modules/` (see `CLAUDE.md` for the `ClawModule` pattern). - Use split adapters in `shared/adapters/` and the `useAdapterCall` hook for data fetching. +## Adding or Updating Model Providers + +ClawMaster is a UI and local service layer for OpenClaw. A provider should usually be supported by OpenClaw first; ClawMaster then makes that provider easy to discover, validate, and configure in the setup wizard and Models page. + +Before opening a provider PR: + +- Open or link an issue that identifies the provider, API docs, default base URL, API-key page, supported model IDs, and whether the API is native OpenAI-compatible. +- Confirm the OpenClaw runtime provider id. Use that id in ClawMaster model refs (`provider/model`) unless there is already a documented alias. +- Do not add dependencies for provider integration. Provider setup should use existing adapters, fetch helpers, and config writers. + +Provider UI source of truth: + +- Add the provider to `packages/web/src/modules/setup/types.ts` in `PROVIDERS`. +- Include `label`, `keyUrl`, `models`, `defaultModel`, and `baseUrl` when the endpoint is fixed. +- Set `api: 'openai-completions'` for OpenAI-compatible chat/completions providers that OpenClaw should persist with that API mode. +- Use `labelByLocale` and `credentialLabelByLocale` only when the display name or credential name needs localization beyond the default English label and `API Key`. +- Add the provider id to `TEXT_PROVIDER_TIERS` so it appears in both the setup wizard and Models add-provider dialog. Image-only providers must use `kind: 'text-to-image'` and belong in `PRIMARY_IMAGE_PROVIDERS` instead. +- Use `runtimeProviderId` only when the UI entry intentionally writes to another OpenClaw provider key, such as a text-to-image variant sharing a chat provider account. +- Use `configKeyOverride` only for legacy OpenClaw config compatibility. + +Live model catalogs: + +- Add the provider default base URL to both `packages/web/src/shared/providerCatalog.ts` and `packages/backend/src/services/providerCatalogService.ts` when the Models page should fetch `/models` in desktop and web modes. +- Keep catalog allowlists strict. Non-custom providers must only accept their documented host, protocol, port, and base path. The custom OpenAI-compatible provider is the only path that may accept arbitrary public hosts. +- Add response filtering when a provider returns embeddings, image models, OCR models, moderation models, or other non-chat entries in the same catalog. +- Keep frontend and backend catalog behavior equivalent; the backend service powers web mode, while the frontend helper powers Tauri mode. + +Validation and persistence: + +- Provider key validation is implemented in `packages/web/src/modules/setup/adapters.ts`. +- OpenAI-compatible providers should work through the existing chat/completions probe and `/models` fallback. Do not add provider-specific HTTP code unless the provider is not compatible with the common flow. +- Saved provider config should include `apiKey`, `baseUrl`, `api` when needed, and the static fallback `models` list. The Models page uses that saved list when live catalog discovery is unavailable. +- Default model refs must use the OpenClaw runtime provider id, for example `zai/glm-5.1`. + +Tests required for provider PRs: + +- `packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx`: provider appears in the wizard and passes the expected provider id, key, and base URL into validation. +- `packages/web/src/modules/models/__tests__/ModelsPage.test.tsx`: provider appears in the add dialog and configured-provider card, including live catalog behavior when supported. +- `packages/web/src/modules/setup/__tests__/realAdapter.test.ts`: saved config shape includes the expected API mode, base URL, and fallback models. +- `packages/web/src/shared/providerCatalog.test.ts`: catalog request URL, safety checks, and response filtering. +- `packages/backend/src/services/providerCatalogService.test.ts`: matching backend catalog behavior for web mode. + +Run at least: + +```bash +(cd packages/web && npx vitest run src/shared/providerCatalog.test.ts src/modules/setup/__tests__/realAdapter.test.ts src/modules/setup/__tests__/SetupWizard.test.tsx src/modules/models/__tests__/ModelsPage.test.tsx) +npm test --workspace=@openclaw-manager/backend +npm run build --workspace=@openclaw-manager/web +npm run build --workspace=@openclaw-manager/backend +``` + ## i18n Rules (internationalization / 国际化) All user-facing UI text **must** go through the `t()` translation function. Hard-coded strings in components are not accepted. diff --git a/packages/backend/src/services/providerCatalogService.test.ts b/packages/backend/src/services/providerCatalogService.test.ts index dbabe870..9a1d7925 100644 --- a/packages/backend/src/services/providerCatalogService.test.ts +++ b/packages/backend/src/services/providerCatalogService.test.ts @@ -64,3 +64,40 @@ test('listProviderModels normalizes custom OpenAI-compatible chat completions ba globalThis.fetch = originalFetch } }) + +test('listProviderModels uses the native Z.AI GLM catalog endpoint', async () => { + const originalFetch = globalThis.fetch + let requestedUrl = '' + let requestedAuthorization = '' + + globalThis.fetch = async (input, init) => { + requestedUrl = String(input) + requestedAuthorization = String((init?.headers as Record | undefined)?.Authorization ?? '') + return new Response(JSON.stringify({ + data: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'embedding-3', name: 'Embedding' }, + ], + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + try { + const result = await listProviderModels({ + providerId: 'zai', + apiKey: 'zai-key', + }) + + assert.equal(requestedUrl, 'https://api.z.ai/api/paas/v4/models') + assert.equal(requestedAuthorization, 'Bearer zai-key') + assert.deepEqual(result, [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + ]) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/packages/backend/src/services/providerCatalogService.ts b/packages/backend/src/services/providerCatalogService.ts index b93fe07c..e0a9cc04 100644 --- a/packages/backend/src/services/providerCatalogService.ts +++ b/packages/backend/src/services/providerCatalogService.ts @@ -23,6 +23,7 @@ const OPENAI_COMPATIBLE_PROVIDER_DEFAULTS: Record = { 'kimi-coding': 'https://api.moonshot.cn/v1', siliconflow: 'https://api.siliconflow.cn/v1', 'baidu-aistudio': 'https://aistudio.baidu.com/llm/lmapi/v3', + zai: 'https://api.z.ai/api/paas/v4', openrouter: 'https://openrouter.ai/api/v1', cerebras: 'https://api.cerebras.ai/v1', } @@ -189,6 +190,8 @@ function filterProviderCatalogModels(providerId: string, models: ProviderCatalog return models.filter((model) => !/(embed|moderation)/i.test(model.id)) case 'baidu-aistudio': return models.filter((model) => !/(embedding|bge|stable-diffusion|infer-|sft-)/i.test(model.id)) + case 'zai': + return models.filter((model) => /^glm-/i.test(model.id) && !/(embedding|image|ocr)/i.test(model.id)) default: return models } diff --git a/packages/web/src/modules/models/__tests__/ModelsPage.test.tsx b/packages/web/src/modules/models/__tests__/ModelsPage.test.tsx index 78e8761d..9bb9f47b 100644 --- a/packages/web/src/modules/models/__tests__/ModelsPage.test.tsx +++ b/packages/web/src/modules/models/__tests__/ModelsPage.test.tsx @@ -172,6 +172,82 @@ describe('ModelsPage', () => { }) }) + it('lists GLM as a native provider in the add panel and can submit it', async () => { + renderPage() + + expect(await screen.findByRole('heading', { name: 'Model Configuration' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '+ Add Provider' })) + expect(await screen.findByRole('heading', { name: 'Add Provider' })).toBeInTheDocument() + + const panel = within(getElementById('models-add-provider')) + + fireEvent.click(panel.getByRole('button', { name: 'GLM (Z.AI)' })) + expect(screen.getByRole('link', { name: 'Get GLM (Z.AI) API Key →' })).toHaveAttribute( + 'href', + 'https://z.ai/manage-apikey/apikey-list', + ) + expect(screen.getAllByText('GLM-5.1 / GLM-5 / GLM-5 Turbo').length).toBeGreaterThan(0) + + fireEvent.change(screen.getByPlaceholderText('Enter GLM (Z.AI) API Key'), { + target: { value: 'zai-key' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Verify & Add' })) + + await waitFor(() => { + expect(mockTestApiKey).toHaveBeenCalledWith('zai', 'zai-key', undefined) + }) + await waitFor(() => { + expect(mockSetApiKey).toHaveBeenCalledWith('zai', 'zai-key', undefined) + }) + }) + + it('loads the native GLM live catalog for configured Z.AI providers', async () => { + mockGetConfig.mockResolvedValueOnce({ + agents: { + defaults: { + model: { primary: 'zai/glm-5.1' }, + imageGenerationModel: { primary: '' }, + }, + }, + models: { + providers: { + zai: { + apiKey: 'zai-key', + api: 'openai-completions', + baseUrl: 'https://api.z.ai/api/paas/v4', + models: [{ id: 'glm-5.1', name: 'GLM-5.1' }], + }, + }, + }, + }) + mockGetProviderModelCatalog.mockResolvedValueOnce({ + success: true, + data: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + ], + error: null, + }) + + renderPage() + + expect(await screen.findByRole('heading', { name: 'Model Configuration' })).toBeInTheDocument() + expect(screen.getByText('GLM (Z.AI)')).toBeInTheDocument() + await waitFor(() => { + expect(mockGetProviderModelCatalog).toHaveBeenCalledWith({ + providerId: 'zai', + apiKey: 'zai-key', + baseUrl: 'https://api.z.ai/api/paas/v4', + }) + }) + + fireEvent.click(within(getProviderCardByLabel('GLM (Z.AI)')).getByRole('button', { name: 'Choose Model' })) + const picker = getElementById('models-provider-picker-zai') + expect(within(picker!).getByText('Live catalog')).toBeInTheDocument() + expect(within(picker!).getByRole('button', { name: /GLM-4\.6 glm-4\.6/ })).toBeInTheDocument() + }) + it('lists ERNIE-Image in the expanded provider catalog and surfaces text-to-image guidance', async () => { mockGetConfig.mockResolvedValueOnce({ agents: { diff --git a/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx b/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx index 11209595..ba81e92c 100644 --- a/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx +++ b/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx @@ -541,6 +541,44 @@ describe('SetupWizard', () => { }) }) + it('configures Z.AI GLM as a native provider in the wizard', async () => { + render( {}} />) + + await screen.findByText('Configure LLM Provider') + + fireEvent.click(screen.getByText('GLM (Z.AI)')) + expect(screen.getByRole('link', { name: 'Get GLM (Z.AI) API Key →' })).toHaveAttribute( + 'href', + 'https://z.ai/manage-apikey/apikey-list', + ) + fireEvent.change(screen.getByPlaceholderText(/Enter GLM \(Z\.AI\) API Key/i), { + target: { value: 'zai-key' }, + }) + fireEvent.click(screen.getByRole('button', { name: /Validate & Continue/i })) + + await waitFor(() => { + expect(mockSetupAdapter.onboarding.testApiKey).toHaveBeenCalledWith( + 'zai', + 'zai-key', + 'https://api.z.ai/api/paas/v4', + ) + }) + await waitFor(() => { + expect(mockSetupAdapter.onboarding.setApiKey).toHaveBeenCalledWith( + 'zai', + 'zai-key', + 'https://api.z.ai/api/paas/v4', + ) + }) + await waitFor(() => { + expect(mockGetProviderModelCatalogResult).toHaveBeenCalledWith({ + providerId: 'zai', + apiKey: 'zai-key', + baseUrl: 'https://api.z.ai/api/paas/v4', + }) + }) + }) + it('requires revalidation when the API key changes after validation', async () => { render( {}} />) diff --git a/packages/web/src/modules/setup/__tests__/realAdapter.test.ts b/packages/web/src/modules/setup/__tests__/realAdapter.test.ts index adf402b1..3a6198b0 100644 --- a/packages/web/src/modules/setup/__tests__/realAdapter.test.ts +++ b/packages/web/src/modules/setup/__tests__/realAdapter.test.ts @@ -242,6 +242,39 @@ describe('realSetupAdapter', () => { }) }) + it('writes Z.AI GLM as a native OpenAI-compatible provider', async () => { + vi.mocked(setConfigResult).mockResolvedValue({ + success: true, + data: undefined, + error: null, + }) + + await expect( + realSetupAdapter.onboarding.setApiKey('zai', 'zai-key'), + ).resolves.toBeUndefined() + + expect(setConfigResult).toHaveBeenCalledWith('models.providers.zai', { + apiKey: 'zai-key', + api: 'openai-completions', + baseUrl: 'https://api.z.ai/api/paas/v4', + models: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-5', name: 'GLM-5' }, + { id: 'glm-5-turbo', name: 'GLM-5 Turbo' }, + { id: 'glm-5v-turbo', name: 'GLM-5V Turbo' }, + { id: 'glm-4.7', name: 'GLM-4.7' }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash' }, + { id: 'glm-4.7-flashx', name: 'GLM-4.7 FlashX' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + { id: 'glm-4.6v', name: 'GLM-4.6V' }, + { id: 'glm-4.5', name: 'GLM-4.5' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air' }, + { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash' }, + { id: 'glm-4.5v', name: 'GLM-4.5V' }, + ], + }) + }) + it('writes the ERNIE provider as a custom openai-compatible provider', async () => { vi.mocked(setConfigResult).mockResolvedValue({ success: true, diff --git a/packages/web/src/modules/setup/types.ts b/packages/web/src/modules/setup/types.ts index b4b0baae..ade424dd 100644 --- a/packages/web/src/modules/setup/types.ts +++ b/packages/web/src/modules/setup/types.ts @@ -228,6 +228,33 @@ export const PROVIDERS: Record = { ], defaultModel: 'deepseek-chat', }, + zai: { + label: 'GLM (Z.AI)', + labelByLocale: { + zh: '智谱 GLM', + en: 'GLM (Z.AI)', + ja: 'GLM (Z.AI)', + }, + api: 'openai-completions', + keyUrl: 'https://z.ai/manage-apikey/apikey-list', + baseUrl: 'https://api.z.ai/api/paas/v4', + models: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-5', name: 'GLM-5' }, + { id: 'glm-5-turbo', name: 'GLM-5 Turbo' }, + { id: 'glm-5v-turbo', name: 'GLM-5V Turbo' }, + { id: 'glm-4.7', name: 'GLM-4.7' }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash' }, + { id: 'glm-4.7-flashx', name: 'GLM-4.7 FlashX' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + { id: 'glm-4.6v', name: 'GLM-4.6V' }, + { id: 'glm-4.5', name: 'GLM-4.5' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air' }, + { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash' }, + { id: 'glm-4.5v', name: 'GLM-4.5V' }, + ], + defaultModel: 'glm-5.1', + }, minimax: { label: 'MiniMax', keyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', @@ -504,6 +531,7 @@ export const TEXT_PROVIDER_TIERS: readonly ProviderTier[] = [ 'anthropic', 'google', 'deepseek', + 'zai', 'kimi-coding', 'minimax', 'siliconflow', diff --git a/packages/web/src/shared/providerCatalog.test.ts b/packages/web/src/shared/providerCatalog.test.ts index 239412d0..08827049 100644 --- a/packages/web/src/shared/providerCatalog.test.ts +++ b/packages/web/src/shared/providerCatalog.test.ts @@ -157,4 +157,40 @@ describe('providerCatalog', () => { expect(request?.url).toBe('https://open.bigmodel.cn/api/paas/v4/models') }) }) + + describe('zai catalog', () => { + it('buildProviderCatalogRequest targets the native GLM catalog endpoint with Bearer auth', () => { + const request = buildProviderCatalogRequest({ + providerId: 'zai', + apiKey: 'zai-key', + }) + + expect(request).toEqual({ + url: 'https://api.z.ai/api/paas/v4/models', + headers: { Authorization: 'Bearer zai-key' }, + }) + }) + + it('assertSafeProviderCatalogBaseUrl accepts the official Z.AI base URL', () => { + expect(() => + assertSafeProviderCatalogBaseUrl('zai', 'https://api.z.ai/api/paas/v4'), + ).not.toThrow() + }) + + it('filters the native GLM catalog to text model ids', () => { + const result = normalizeProviderCatalogResponse('zai', { + data: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + { id: 'embedding-3', name: 'Embedding' }, + { id: 'glm-image', name: 'GLM Image' }, + ], + }) + + expect(result).toEqual([ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + ]) + }) + }) }) diff --git a/packages/web/src/shared/providerCatalog.ts b/packages/web/src/shared/providerCatalog.ts index 0c129f21..4444a6ad 100644 --- a/packages/web/src/shared/providerCatalog.ts +++ b/packages/web/src/shared/providerCatalog.ts @@ -25,6 +25,7 @@ const OPENAI_COMPATIBLE_PROVIDER_DEFAULTS: Record = { 'kimi-coding': 'https://api.moonshot.cn/v1', siliconflow: 'https://api.siliconflow.cn/v1', 'baidu-aistudio': 'https://aistudio.baidu.com/llm/lmapi/v3', + zai: 'https://api.z.ai/api/paas/v4', openrouter: 'https://openrouter.ai/api/v1', cerebras: 'https://api.cerebras.ai/v1', } @@ -214,6 +215,10 @@ function isBaiduTextModel(id: string) { return !/(embedding|bge|stable-diffusion|infer-|sft-)/i.test(id) } +function isZaiTextModel(id: string) { + return /^glm-/i.test(id) && !/(embedding|image|ocr)/i.test(id) +} + function filterProviderCatalogModels(providerId: string, models: ProviderCatalogModel[]) { switch (providerId) { case 'openai': @@ -222,6 +227,8 @@ function filterProviderCatalogModels(providerId: string, models: ProviderCatalog return models.filter((model) => isMistralTextModel(model.id)) case 'baidu-aistudio': return models.filter((model) => isBaiduTextModel(model.id)) + case 'zai': + return models.filter((model) => isZaiTextModel(model.id)) default: return models } From 1f8cb89f79e383eaf3e4dd0de0c90f740006c117 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Tue, 28 Apr 2026 12:07:16 +0800 Subject: [PATCH 03/27] Add Baidu Qianfan Coding Plan provider --- CONTRIBUTING.md | 6 +- .../services/providerCatalogService.test.ts | 39 ++++++++ .../src/services/providerCatalogService.ts | 11 +++ packages/web/src/locales/main/en.ts | 1 + packages/web/src/locales/main/ja.ts | 1 + packages/web/src/locales/main/zh.ts | 1 + .../web/src/modules/models/ModelsPage.tsx | 4 +- .../models/__tests__/ModelsPage.test.tsx | 97 +++++++++++++++++-- .../setup/__tests__/SetupWizard.test.tsx | 43 +++++++- .../setup/__tests__/realAdapter.test.ts | 50 ++++++++++ packages/web/src/modules/setup/types.ts | 29 +++++- .../web/src/shared/providerCatalog.test.ts | 44 +++++++++ packages/web/src/shared/providerCatalog.ts | 13 +++ tests/desktop/harness.mjs | 23 +++-- tests/ui/13-models-module.yaml | 28 ++++-- 15 files changed, 358 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 611af198..9e7ef98e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,8 @@ Before opening a provider PR: - Open or link an issue that identifies the provider, API docs, default base URL, API-key page, supported model IDs, and whether the API is native OpenAI-compatible. - Confirm the OpenClaw runtime provider id. Use that id in ClawMaster model refs (`provider/model`) unless there is already a documented alias. +- If the vendor docs include an OpenClaw config snippet, copy the exact provider id, `baseUrl`, `api` mode, and default model into the issue before coding. +- Test credentials may be used for local smoke checks, but never commit real keys, screenshots that reveal keys, terminal logs containing keys, or `.env` files. - Do not add dependencies for provider integration. Provider setup should use existing adapters, fetch helpers, and config writers. Provider UI source of truth: @@ -71,12 +73,14 @@ Live model catalogs: - Add the provider default base URL to both `packages/web/src/shared/providerCatalog.ts` and `packages/backend/src/services/providerCatalogService.ts` when the Models page should fetch `/models` in desktop and web modes. - Keep catalog allowlists strict. Non-custom providers must only accept their documented host, protocol, port, and base path. The custom OpenAI-compatible provider is the only path that may accept arbitrary public hosts. - Add response filtering when a provider returns embeddings, image models, OCR models, moderation models, or other non-chat entries in the same catalog. +- If `/models` returns a broad catalog or omits a documented default alias, keep the documented fallback `models` list in `PROVIDERS` and filter the live catalog to the provider's intended capability. For example, coding-plan providers should not surface unrelated image, OCR, embedding, or generic chat entries. - Keep frontend and backend catalog behavior equivalent; the backend service powers web mode, while the frontend helper powers Tauri mode. Validation and persistence: - Provider key validation is implemented in `packages/web/src/modules/setup/adapters.ts`. - OpenAI-compatible providers should work through the existing chat/completions probe and `/models` fallback. Do not add provider-specific HTTP code unless the provider is not compatible with the common flow. +- Smoke-test the documented default model with `POST /chat/completions` and, when catalog support is enabled, `GET /models`. Record only status and sanitized findings in the issue or PR. - Saved provider config should include `apiKey`, `baseUrl`, `api` when needed, and the static fallback `models` list. The Models page uses that saved list when live catalog discovery is unavailable. - Default model refs must use the OpenClaw runtime provider id, for example `zai/glm-5.1`. @@ -152,7 +156,7 @@ Every pull request must be tested before review: > [!WARNING] > PRs containing any of the following will be asked to remove them before merge: -- **No screenshots or screen recordings** — post demos in [Discussions](https://github.com/openmaster-ai/clawmaster/discussions) instead. +- **No committed screenshots or screen recordings** — UI PRs should include screenshots or short recordings in the PR body under **## Screenshots**, but those assets must not be committed to the repo. - **No test output logs** or captured terminal output pasted inline. - **No debug `console.log` calls** left in production code paths. - **No generated files**: `dist/`, `coverage/`, `*.tsbuildinfo`, `src-tauri/target/`. diff --git a/packages/backend/src/services/providerCatalogService.test.ts b/packages/backend/src/services/providerCatalogService.test.ts index 9a1d7925..37dc62c7 100644 --- a/packages/backend/src/services/providerCatalogService.test.ts +++ b/packages/backend/src/services/providerCatalogService.test.ts @@ -101,3 +101,42 @@ test('listProviderModels uses the native Z.AI GLM catalog endpoint', async () => globalThis.fetch = originalFetch } }) + +test('listProviderModels uses the Baidu BCE Qianfan coding catalog endpoint', async () => { + const originalFetch = globalThis.fetch + let requestedUrl = '' + let requestedAuthorization = '' + + globalThis.fetch = async (input, init) => { + requestedUrl = String(input) + requestedAuthorization = String((init?.headers as Record | undefined)?.Authorization ?? '') + return new Response(JSON.stringify({ + data: [ + { id: 'ernie-4.5-turbo-128k', name: 'ERNIE 4.5 Turbo' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B' }, + { id: 'qwen3-embedding-4b', name: 'Qwen3 Embedding' }, + ], + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + try { + const result = await listProviderModels({ + providerId: 'baiduqianfancodingplan', + apiKey: 'bce-key', + }) + + assert.equal(requestedUrl, 'https://qianfan.baidubce.com/v2/coding/models') + assert.equal(requestedAuthorization, 'Bearer bce-key') + assert.deepEqual(result, [ + { id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B' }, + ]) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/packages/backend/src/services/providerCatalogService.ts b/packages/backend/src/services/providerCatalogService.ts index e0a9cc04..115f7ef3 100644 --- a/packages/backend/src/services/providerCatalogService.ts +++ b/packages/backend/src/services/providerCatalogService.ts @@ -23,6 +23,7 @@ const OPENAI_COMPATIBLE_PROVIDER_DEFAULTS: Record = { 'kimi-coding': 'https://api.moonshot.cn/v1', siliconflow: 'https://api.siliconflow.cn/v1', 'baidu-aistudio': 'https://aistudio.baidu.com/llm/lmapi/v3', + baiduqianfancodingplan: 'https://qianfan.baidubce.com/v2/coding', zai: 'https://api.z.ai/api/paas/v4', openrouter: 'https://openrouter.ai/api/v1', cerebras: 'https://api.cerebras.ai/v1', @@ -190,6 +191,16 @@ function filterProviderCatalogModels(providerId: string, models: ProviderCatalog return models.filter((model) => !/(embed|moderation)/i.test(model.id)) case 'baidu-aistudio': return models.filter((model) => !/(embedding|bge|stable-diffusion|infer-|sft-)/i.test(model.id)) + case 'baiduqianfancodingplan': { + const codingModels = models.filter((model) => + /(?:qianfan-code|coder|codellama|sqlcoder)/i.test(model.id) && + !/(embedding|bge|image|ocr|stable-diffusion|wan-|vl)/i.test(model.id), + ) + if (!codingModels.some((model) => model.id === 'qianfan-code-latest')) { + codingModels.unshift({ id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }) + } + return codingModels + } case 'zai': return models.filter((model) => /^glm-/i.test(model.id) && !/(embedding|image|ocr)/i.test(model.id)) default: diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index 143956a8..03de2ac3 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -306,6 +306,7 @@ export default { "providers.tierFeatured": "Featured providers", "providers.tierFeaturedMore": "More providers ({{count}})", "providers.tierCompatibleAndLocal": "Compatible & local", + "providers.baiduQianfanCodingPlanNote": "Uses Baidu BCE Qianfan Coding Plan's OpenAI-compatible coding endpoint.", "providers.ernieQuotaNote": "Get 1,000,000 free tokens after registration, then another 1,000,000 after completing your profile.", "providers.ernieImageNote": "Uses the same Baidu AI Studio Access Token. Verification performs a real image generation request.", "providers.ernieImageGuide": "Use this provider for image generation, not as the primary agent chat model.", diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index 3bee87de..c4819ebe 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -306,6 +306,7 @@ export default { "providers.tierFeatured": "おすすめプロバイダー", "providers.tierFeaturedMore": "他のプロバイダー({{count}})", "providers.tierCompatibleAndLocal": "互換エンドポイント・ローカル", + "providers.baiduQianfanCodingPlanNote": "Baidu BCE Qianfan Coding Plan の OpenAI 互換コードモデルエンドポイントを使います。", "providers.ernieQuotaNote": "登録後に100万トークン、プロフィール情報の入力完了後にさらに100万トークンを受け取れます。", "providers.ernieImageNote": "ERNIE LLM API と同じ Baidu AI Studio アクセストークンを使います。検証では実際の画像生成リクエストを送信します。", "providers.ernieImageGuide": "このプロバイダーは画像生成用です。エージェントの主会話モデルには使わないでください。", diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index 64a78a73..c9d1366c 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -306,6 +306,7 @@ export default { "providers.tierFeatured": "推荐提供商", "providers.tierFeaturedMore": "其他提供商({{count}})", "providers.tierCompatibleAndLocal": "兼容端点 · 本地", + "providers.baiduQianfanCodingPlanNote": "使用百度 BCE 千帆 Coding Plan 的 OpenAI 兼容代码模型端点。", "providers.ernieQuotaNote": "注册后可领取 100 万 Tokens,完善用户资料后再领取 100 万 Tokens。", "providers.ernieImageNote": "与文心大模型共用同一枚 Baidu AI Studio 令牌。验证时会实际发起一次图像生成请求。", "providers.ernieImageGuide": "这个提供商用于图像生成,不要把它当作智能体对话主模型。", diff --git a/packages/web/src/modules/models/ModelsPage.tsx b/packages/web/src/modules/models/ModelsPage.tsx index 27fbcc73..d22d3281 100644 --- a/packages/web/src/modules/models/ModelsPage.tsx +++ b/packages/web/src/modules/models/ModelsPage.tsx @@ -259,7 +259,7 @@ export default function Models() { const [, setModels] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) - const [preferredProvider, setPreferredProvider] = useState('baidu-aistudio') + const [preferredProvider, setPreferredProvider] = useState('baiduqianfancodingplan') const loadData = useCallback(async () => { try { @@ -314,7 +314,7 @@ export default function Models() {
+ +
+
+ +

+ {t('gateway.safeguard')} +

+
+

{watchdogSummary}

+
+
+

{t('gateway.safeguardState')}

+

{watchdogStateLabel}

+
+
+

{t('gateway.safeguardRestarts')}

+

{watchdog?.restartCount ?? 0}

+
+
+ {watchdog?.lastError ? ( +

{watchdog.lastError}

+ ) : null} +
@@ -229,7 +272,7 @@ export default function Gateway() {

- {status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')} + {watchdog?.enabled ? t('gateway.safeguardHelp') : status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')}

)} @@ -258,7 +301,7 @@ export default function Gateway() { {gatewayToken && (
-

Token

+

{t('gateway.token')}

)} diff --git a/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx b/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx index 4f2b6afe..3009ff47 100644 --- a/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx +++ b/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx @@ -123,6 +123,30 @@ describe('GatewayPage', () => { expect(await screen.findByText('Token copied')).toBeInTheDocument() }) + it('shows the service watchdog safeguard state when available', async () => { + mockGetGatewayStatus.mockResolvedValue({ + success: true, + data: { + running: true, + port: 18789, + watchdog: { + enabled: true, + state: 'healthy', + intervalMs: 30000, + restartCount: 2, + lastCheckAt: '2026-04-28T00:00:00.000Z', + }, + }, + }) + + renderGatewayPage() + + expect((await screen.findAllByText('Auto-restart enabled')).length).toBeGreaterThan(0) + expect(screen.getByText('Healthy')).toBeInTheDocument() + expect(screen.getByText('ClawMaster service monitors OpenClaw gateway and restarts it after unexpected downtime.')).toBeInTheDocument() + expect(screen.getAllByText('2').length).toBeGreaterThan(0) + }) + it('starts a stopped gateway and refreshes the runtime controls', async () => { mockGetGatewayStatus .mockResolvedValueOnce({ success: true, data: { running: false, port: 18789 } }) From fef36223e26bc1529b6a573392c94882b5f10a08 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 11:27:13 +0800 Subject: [PATCH 05/27] Fix Windows WSL workspace path and Vitest workers --- .../src/shared/__tests__/vitestConfig.test.ts | 23 +++++++++++++++++++ packages/web/vitest.config.ts | 8 ++----- packages/web/vitest.testConfig.ts | 19 +++++++++++++++ .../workspaceImport.test.ts | 20 ++++++++++++++++ .../workspaceImport.ts | 15 ++++++++++-- 5 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/shared/__tests__/vitestConfig.test.ts create mode 100644 packages/web/vitest.testConfig.ts diff --git a/packages/web/src/shared/__tests__/vitestConfig.test.ts b/packages/web/src/shared/__tests__/vitestConfig.test.ts new file mode 100644 index 00000000..fde6d215 --- /dev/null +++ b/packages/web/src/shared/__tests__/vitestConfig.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { createWebVitestTestConfig } from '../../../vitest.testConfig' + +describe('createWebVitestTestConfig', () => { + it('uses a single thread worker on Windows to avoid fork startup timeouts', () => { + expect(createWebVitestTestConfig('win32')).toMatchObject({ + pool: 'threads', + fileParallelism: false, + maxWorkers: 1, + minWorkers: 1, + }) + }) + + it('does not constrain worker settings on non-Windows platforms', () => { + expect(createWebVitestTestConfig('linux')).not.toMatchObject({ + pool: expect.any(String), + fileParallelism: expect.any(Boolean), + maxWorkers: expect.any(Number), + minWorkers: expect.any(Number), + }) + }) +}) diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index d9f3a862..62f868d4 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' +import { createWebVitestTestConfig } from './vitest.testConfig' export default defineConfig({ plugins: [react()], @@ -9,10 +10,5 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.test.{ts,tsx}'], - }, + test: createWebVitestTestConfig(), }) diff --git a/packages/web/vitest.testConfig.ts b/packages/web/vitest.testConfig.ts new file mode 100644 index 00000000..e5067657 --- /dev/null +++ b/packages/web/vitest.testConfig.ts @@ -0,0 +1,19 @@ +export function createWebVitestTestConfig(platform: NodeJS.Platform = process.platform) { + const windowsWorkerConfig = + platform === 'win32' + ? { + pool: 'threads' as const, + fileParallelism: false, + maxWorkers: 1, + minWorkers: 1, + } + : {} + + return { + environment: 'jsdom' as const, + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + ...windowsWorkerConfig, + } +} diff --git a/plugins/memory-clawmaster-powermem/workspaceImport.test.ts b/plugins/memory-clawmaster-powermem/workspaceImport.test.ts index 933265e2..7a64934c 100644 --- a/plugins/memory-clawmaster-powermem/workspaceImport.test.ts +++ b/plugins/memory-clawmaster-powermem/workspaceImport.test.ts @@ -199,6 +199,26 @@ test('resolveOpenclawWorkspaceDir prefers WSL HOME when the managed data root is } }) +test('resolveOpenclawWorkspaceDir keeps Windows data root workspaces in Windows path style', () => { + const previousStateDir = process.env['OPENCLAW_STATE_DIR'] + delete process.env['OPENCLAW_STATE_DIR'] + + try { + const workspaceDir = resolveOpenclawWorkspaceDir({ + dataRootOverride: 'C:\\Users\\alice\\.clawmaster\\data\\named\\team-a', + engineOverride: 'powermem-sqlite', + }) + + assert.equal(workspaceDir, 'C:\\Users\\alice\\.openclaw-team-a\\workspace') + } finally { + if (previousStateDir === undefined) { + delete process.env['OPENCLAW_STATE_DIR'] + } else { + process.env['OPENCLAW_STATE_DIR'] = previousStateDir + } + } +}) + test('importOpenclawWorkspaceMemories reads named-profile workspace files from dataRootOverride without OPENCLAW_STATE_DIR', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-plugin-workspace-import-named-')) const previousStateDir = process.env['OPENCLAW_STATE_DIR'] diff --git a/plugins/memory-clawmaster-powermem/workspaceImport.ts b/plugins/memory-clawmaster-powermem/workspaceImport.ts index 9bc290ce..28510f75 100644 --- a/plugins/memory-clawmaster-powermem/workspaceImport.ts +++ b/plugins/memory-clawmaster-powermem/workspaceImport.ts @@ -92,6 +92,17 @@ function fromForwardSlashes(value: string, windowsStyle: boolean): string { return windowsStyle ? value.replace(/\//g, '\\') : value } +function joinTargetRuntimePath(root: string, child: string): string { + const normalizedRoot = root.trim() + if (/^[A-Za-z]:[\\/]/.test(normalizedRoot) || normalizedRoot.startsWith('\\\\')) { + return path.win32.join(normalizedRoot, child) + } + if (normalizedRoot.startsWith('/')) { + return path.posix.join(normalizedRoot, child) + } + return path.join(normalizedRoot, child) +} + function resolvePreferredPosixHomeForMountedManagedDataRoot(normalizedDataRoot: string): string | null { const homeDir = process.env['HOME']?.trim() if (!homeDir || !homeDir.startsWith('/home/')) { @@ -142,12 +153,12 @@ function resolveOpenclawStateDirFromManagedDataRoot(dataRoot: string): string | export function resolveOpenclawWorkspaceDir(context: ManagedMemoryContext = {}): string { const stateDir = process.env['OPENCLAW_STATE_DIR']?.trim() if (stateDir) { - return path.join(stateDir, 'workspace') + return joinTargetRuntimePath(stateDir, 'workspace') } const derivedStateDir = context.dataRootOverride ? resolveOpenclawStateDirFromManagedDataRoot(context.dataRootOverride) : null if (derivedStateDir) { - return path.join(derivedStateDir, 'workspace') + return joinTargetRuntimePath(derivedStateDir, 'workspace') } return path.join(process.env['HOME'] || process.cwd(), '.openclaw', 'workspace') } From 7cf7f264cdffaf7d06e1620b7d54b2ac8bf8cf00 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 14:46:25 +0800 Subject: [PATCH 06/27] feat: add package download tracker skill --- .../package-download-tracker/SKILL.md | 78 ++ .../package-download-tracker/_meta.json | 5 + .../scripts/common.mjs | 755 ++++++++++++++++++ .../scripts/track-downloads.mjs | 21 + package.json | 1 + packages/backend/src/index.ts | 4 + .../src/services/bundledSkills.test.ts | 46 +- .../backend/src/services/bundledSkills.ts | 4 + .../packageDownloadTrackerSkill.test.ts | 573 +++++++++++++ packages/web/src/i18n/en.json | 1 + packages/web/src/i18n/ja.json | 1 + packages/web/src/i18n/zh.json | 1 + packages/web/src/locales/main/en.ts | 8 + packages/web/src/locales/main/ja.ts | 8 + packages/web/src/locales/main/zh.ts | 8 + packages/web/src/modules/cron/CronPage.tsx | 32 +- .../modules/cron/__tests__/CronPage.test.tsx | 32 + .../modules/skills/__tests__/catalog.test.ts | 6 + packages/web/src/modules/skills/catalog.ts | 9 + .../shared/adapters/__tests__/cron.test.ts | 3 +- packages/web/src/shared/adapters/clawhub.ts | 1 + packages/web/src/shared/adapters/cron.ts | 2 +- packages/web/src/shared/cronCostDigests.ts | 27 + scripts/prepare-tauri-memory-plugin.mjs | 5 + src-tauri/src/lib.rs | 16 +- 25 files changed, 1637 insertions(+), 10 deletions(-) create mode 100644 bundled-skills/package-download-tracker/SKILL.md create mode 100644 bundled-skills/package-download-tracker/_meta.json create mode 100644 bundled-skills/package-download-tracker/scripts/common.mjs create mode 100644 bundled-skills/package-download-tracker/scripts/track-downloads.mjs create mode 100644 packages/backend/src/services/packageDownloadTrackerSkill.test.ts diff --git a/bundled-skills/package-download-tracker/SKILL.md b/bundled-skills/package-download-tracker/SKILL.md new file mode 100644 index 00000000..99d02c32 --- /dev/null +++ b/bundled-skills/package-download-tracker/SKILL.md @@ -0,0 +1,78 @@ +--- +name: package-download-tracker +description: Track npm and PyPI package downloads by week or month, use stored history observations, and save compact trend analysis for future package adoption questions. Use when the user asks for npm downloads, PyPI downloads, package popularity, package growth, or recurring package download trend tracking. +metadata: + openclaw: + requires: + anyBins: + - node +--- + +# package-download-tracker + +Use this skill when OpenClaw needs current npm or PyPI package download trends, especially for recurring weekly or monthly analysis. + +This skill fetches registry download data, keeps a local normalized current window, and can use saved observations as history so follow-up trend explanations do not need broad historical registry queries. + +## Critical Rule + +This skill is guidance plus runnable scripts, not a callable tool name. + +- Do not call `package-download-tracker` as if it were a built-in tool. +- First use `read` to load this `SKILL.md`. +- Then use `exec` to run the bundled Node script with `node`. +- When the user asks for trend analysis over time, use `--load-memory --save-memory` so previous observations are recalled and refreshed. +- Treat PowerMem recall/save warnings, cache details, script commands, and stored-observation mechanics as internal diagnostics. Do not surface them to the user unless they explicitly ask about implementation mechanics. + +## Data Sources + +- npm: `https://api.npmjs.org/downloads/range//` +- PyPI: `https://pypistats.org/api/packages//overall` and `recent` + +## Script Directory + +Treat the directory containing this `SKILL.md` as `SKILL_DIR`, then use: + +| Script | Purpose | +|---|---| +| `scripts/track-downloads.mjs` | Fetch package downloads, analyze trends from stored observations, and optionally save a new observation | + +## Commands + +```bash +# Weekly npm package tracker with memory recall and save +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry npm --packages clawmaster,powermem --period week --load-memory --save-memory --summary + +# Monthly PyPI tracker +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry pypi --package powermem --period month --load-memory --save-memory + +# Force a fresh registry request instead of using today's cache +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry npm --package @types/node --period week --refresh +``` + +## Query Flags + +| Flag | Meaning | +|---|---| +| `--registry ` | Required registry source | +| `--package ` | Package to track; repeatable | +| `--packages ` | Comma-separated packages to track | +| `--period ` | Tracking window | +| `--summary` | Print a compact markdown summary instead of JSON | +| `--refresh` | Ignore the local cache for the current window | +| `--load-memory` | Search PowerMem for previous snapshots before registry fetches | +| `--save-memory` | Save the current compact analysis snapshot through `openclaw ltm add` | +| `--cache-path ` | Override the cache directory | +| `--history-limit ` | Number of historical observations to keep in trend analysis; default is `6` | + +## Response Expectations + +When you use this skill: + +1. Put at least two period columns in the user-facing table: `Current week` and `Previous week` for weekly tracking, or `Current month` and `Previous month` for monthly tracking. +2. Base trend analysis on the history observations returned by the script when available. +3. Prefer stored history over repeated broad registry queries just to rebuild old context. +4. Mention npm/PyPI API or data-quality warnings only when they affect the numbers. +5. Keep the table internally consistent: if the previous-period column is `n/a`, `-`, or otherwise unavailable, the trend/change cell must also say there is no previous-period data. Only show a percentage change when the previous-period column shows the numeric baseline used for that calculation. +6. Keep PowerMem recall/save diagnostics, cache details, script commands, and stored-observation mechanics out of the user-facing summary. Describe them only as current data and prior observations. +7. Do not invent download numbers. diff --git a/bundled-skills/package-download-tracker/_meta.json b/bundled-skills/package-download-tracker/_meta.json new file mode 100644 index 00000000..96c9c496 --- /dev/null +++ b/bundled-skills/package-download-tracker/_meta.json @@ -0,0 +1,5 @@ +{ + "slug": "package-download-tracker", + "version": "1.0.0", + "bundled": true +} diff --git a/bundled-skills/package-download-tracker/scripts/common.mjs b/bundled-skills/package-download-tracker/scripts/common.mjs new file mode 100644 index 00000000..b38cb03b --- /dev/null +++ b/bundled-skills/package-download-tracker/scripts/common.mjs @@ -0,0 +1,755 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +export const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.openclaw', 'cache', 'package-download-tracker') +export const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000 +export const SKILL_MARKER = 'package-download-tracker' + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }) +} + +function normalizeToken(value) { + return String(value ?? '').trim().toLowerCase() +} + +function parsePackageList(value) { + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function canonicalPypiPackageName(value) { + return String(value ?? '').trim().toLowerCase().replace(/[-_.]+/g, '-') +} + +export function normalizeRegistry(value) { + const normalized = normalizeToken(value) + if (normalized !== 'npm' && normalized !== 'pypi') { + throw new Error(`Unsupported --registry value: ${value}`) + } + return normalized +} + +export function normalizePeriod(value) { + const normalized = normalizeToken(value || 'week') + if (normalized !== 'week' && normalized !== 'month') { + throw new Error(`Unsupported --period value: ${value}`) + } + return normalized +} + +export function normalizePackageName(registry, value) { + const name = String(value ?? '').trim() + if (!name) throw new Error('Package name is required') + + if (registry === 'npm') { + if (/\s/.test(name)) throw new Error(`Invalid npm package name: ${name}`) + if (name.startsWith('@')) { + if (!/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._~-]*$/i.test(name)) { + throw new Error(`Invalid npm scoped package name: ${name}`) + } + return name + } + if (!/^[a-z0-9][a-z0-9._~-]*$/i.test(name)) { + throw new Error(`Invalid npm package name: ${name}`) + } + return name + } + + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) { + throw new Error(`Invalid PyPI package name: ${name}`) + } + return canonicalPypiPackageName(name) +} + +export function parseArgs(argv) { + const options = { + registry: '', + packages: [], + period: 'week', + summary: false, + refresh: false, + saveMemory: false, + loadMemory: false, + cachePath: DEFAULT_CACHE_DIR, + maxAgeMs: DEFAULT_MAX_AGE_MS, + historyLimit: 6, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const next = argv[index + 1] + + if (arg === '--registry' && next) { + options.registry = next + index += 1 + } else if (arg === '--package' && next) { + options.packages.push(next) + index += 1 + } else if (arg === '--packages' && next) { + options.packages.push(...parsePackageList(next)) + index += 1 + } else if (arg === '--period' && next) { + options.period = next + index += 1 + } else if (arg === '--summary') { + options.summary = true + } else if (arg === '--refresh') { + options.refresh = true + } else if (arg === '--save-memory') { + options.saveMemory = true + } else if (arg === '--load-memory') { + options.loadMemory = true + } else if (arg === '--cache-path' && next) { + options.cachePath = next + index += 1 + } else if (arg === '--max-age-ms' && next) { + const parsed = Number(next) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --max-age-ms value: ${next}`) + } + options.maxAgeMs = parsed + index += 1 + } else if (arg === '--history-limit' && next) { + const parsed = Number(next) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Invalid --history-limit value: ${next}`) + } + options.historyLimit = parsed + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + + const registry = normalizeRegistry(options.registry) + const period = normalizePeriod(options.period) + const packages = [...new Set(options.packages.map((pkg) => normalizePackageName(registry, pkg)))] + if (packages.length === 0) { + throw new Error('At least one --package or --packages value is required') + } + + return { + ...options, + registry, + period, + packages, + } +} + +function periodDays(period) { + return period === 'month' ? 30 : 7 +} + +function cacheFilePath(cacheDir, registry, packageName, period) { + const key = Buffer.from(`${registry}:${packageName}:${period}`).toString('base64url') + return path.join(cacheDir, `${key}.json`) +} + +function readCache(filePath, maxAgeMs, now = Date.now()) { + try { + const stat = fs.statSync(filePath) + if (now - stat.mtimeMs > maxAgeMs) return null + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch { + return null + } +} + +function writeCache(filePath, payload) { + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') +} + +async function fetchJson(url, fetchImpl = fetch) { + const response = await fetchImpl(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'ClawMaster package-download-tracker', + }, + }) + if (!response.ok) { + throw new Error(`Registry request failed (${response.status}) for ${url}`) + } + return response.json() +} + +function normalizeDailyRows(rows) { + return rows + .map((row) => ({ + date: String(row.date ?? row.day ?? '').slice(0, 10), + downloads: Number(row.downloads ?? row.count ?? 0), + })) + .filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date) && Number.isFinite(row.downloads)) + .sort((a, b) => a.date.localeCompare(b.date)) +} + +function sumDownloads(rows) { + return rows.reduce((sum, row) => sum + row.downloads, 0) +} + +function compareTotals(current, previous) { + if (!Number.isFinite(previous) || previous <= 0) { + return { previous: previous ?? null, absolute: null, percent: null } + } + const absolute = current - previous + return { + previous, + absolute, + percent: absolute / previous, + } +} + +function formatPercent(value) { + return `${(value * 100).toFixed(1)}%` +} + +function formatDownloads(value) { + if (value === null || value === undefined) return 'n/a' + return Number.isFinite(Number(value)) ? Number(value).toLocaleString('en-US') : 'n/a' +} + +function hasDownloads(value) { + if (value === null || value === undefined) return false + return Number.isFinite(Number(value)) +} + +function displayTrendText(result) { + return hasDownloads(result.periodColumns?.[1]?.downloads) + ? result.trend.text + : `No previous ${periodNoun(result.period)} data available.` +} + +function periodNoun(period) { + return period === 'month' ? 'month' : 'week' +} + +function periodLabel(period) { + return period === 'month' ? 'month' : 'week' +} + +function trendDirection(delta) { + return delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat' +} + +function compactDate(value) { + const text = String(value ?? '') + return text.length >= 10 ? text.slice(0, 10) : 'prior run' +} + +function formatHistory(history) { + return history + .map((point) => `${compactDate(point.fetchedAt)} ${point.downloads.toLocaleString('en-US')}`) + .join(' -> ') +} + +function historyDateKey(value, fallback) { + const text = String(value ?? '') + return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : `unknown:${fallback}` +} + +function buildHistory(snapshots, current, fetchedAt, limit) { + const byDate = new Map() + snapshots.forEach((snapshot, index) => { + const downloads = Number(snapshot.totalDownloads) + if (!Number.isFinite(downloads)) return + const observedAt = String(snapshot.fetchedAt ?? '') + const row = { + fetchedAt: observedAt, + downloads, + source: 'memory', + } + const key = historyDateKey(observedAt, index) + const existing = byDate.get(key) + if (!existing || snapshotTimestamp(row) >= snapshotTimestamp(existing)) { + byDate.set(key, row) + } + }) + const rows = [...byDate.values()] + rows.sort((a, b) => snapshotTimestamp(a) - snapshotTimestamp(b)) + const selected = rows.slice(Math.max(0, rows.length - Math.max(0, limit - 1))) + const currentRow = { + fetchedAt, + downloads: current.totals.downloads, + source: current.fromCache ? 'cache' : 'registry', + } + const currentKey = historyDateKey(currentRow.fetchedAt, 'current') + const existingCurrentIndex = selected.findIndex((row, index) => historyDateKey(row.fetchedAt, index) === currentKey) + if (existingCurrentIndex >= 0) { + selected[existingCurrentIndex] = currentRow + } else { + selected.push(currentRow) + } + return selected +} + +function buildTrend(history, comparison) { + if (history.length >= 2) { + const first = history[0] + const previous = history[history.length - 2] + const current = history[history.length - 1] + const delta = current.downloads - previous.downloads + const direction = trendDirection(delta) + const periodDelta = previous.downloads > 0 ? delta / previous.downloads : null + const totalDelta = current.downloads - first.downloads + const totalDirection = trendDirection(totalDelta) + const totalPercent = first.downloads > 0 ? totalDelta / first.downloads : null + const text = periodDelta === null + ? `${direction} ${delta.toLocaleString('en-US')} downloads versus previous observation` + : `${direction} ${formatPercent(periodDelta)} versus previous observation (${compactDate(previous.fetchedAt)})` + return { + direction, + basis: 'history', + text, + points: history.length, + previousDelta: { + absolute: delta, + percent: periodDelta, + }, + overallDelta: { + direction: totalDirection, + absolute: totalDelta, + percent: totalPercent, + text: totalPercent === null + ? `${totalDirection} ${totalDelta.toLocaleString('en-US')} downloads across ${history.length} observations` + : `${totalDirection} ${formatPercent(totalPercent)} across ${history.length} observations`, + }, + } + } + + if (comparison.percent !== null) { + const direction = comparison.absolute > 0 ? 'up' : comparison.absolute < 0 ? 'down' : 'flat' + return { + direction, + basis: 'registry-window', + text: `${direction} ${formatPercent(comparison.percent)} versus previous ${periodLabel(comparison.period ?? 'week')}`, + points: 1, + } + } + + return { + direction: 'unknown', + basis: 'current-window', + text: `No previous ${periodLabel(comparison.period ?? 'period')} data available.`, + points: 1, + } +} + +function buildPeriodColumns(period, history, comparison, currentDownloads) { + const previousHistory = history.length >= 2 ? history[history.length - 2] : null + const hasHistoryPrevious = Number.isFinite(Number(previousHistory?.downloads)) + const hasRegistryPrevious = Number.isFinite(comparison.previous) && ( + Number.isFinite(comparison.percent) || + Number.isFinite(comparison.absolute) || + comparison.previous > 0 + ) + const previousDownloads = hasHistoryPrevious + ? Number(previousHistory.downloads) + : hasRegistryPrevious + ? comparison.previous + : null + const previousSource = hasHistoryPrevious + ? 'history' + : hasRegistryPrevious + ? 'registry-window' + : 'unavailable' + + return [ + { + key: 'current', + label: `Current ${periodNoun(period)}`, + downloads: currentDownloads, + source: 'current', + }, + { + key: 'previous', + label: `Previous ${periodNoun(period)}`, + downloads: previousDownloads, + source: previousSource, + }, + ] +} + +function normalizeNpmPayload(packageName, period, raw) { + const daily = normalizeDailyRows(Array.isArray(raw.downloads) ? raw.downloads : []) + const current = daily.slice(-periodDays(period)) + const previous = daily.slice(Math.max(0, daily.length - periodDays(period) * 2), Math.max(0, daily.length - periodDays(period))) + const total = sumDownloads(current) + return { + source: { + kind: 'npm', + url: `https://api.npmjs.org/downloads/range/last-${period}/${encodeURIComponent(packageName)}`, + }, + daily: current, + totals: { + downloads: total, + days: current.length, + }, + comparison: compareTotals(total, previous.length === periodDays(period) ? sumDownloads(previous) : null), + warnings: current.length === 0 ? ['npm returned no daily download rows'] : [], + } +} + +function normalizePypiPayload(packageName, period, overallRaw, recentRaw) { + const allRows = normalizeDailyRows(Array.isArray(overallRaw.data) ? overallRaw.data : []) + const current = allRows.slice(-periodDays(period)) + const previous = allRows.slice(Math.max(0, allRows.length - periodDays(period) * 2), Math.max(0, allRows.length - periodDays(period))) + const summed = sumDownloads(current) + const recent = isRecord(recentRaw?.data) ? recentRaw.data : {} + const recentKey = period === 'month' ? 'last_month' : 'last_week' + const recentTotal = Number(recent[recentKey]) + const total = Number.isFinite(recentTotal) && recentTotal > 0 ? recentTotal : summed + const warnings = [] + if (current.length === 0) warnings.push('PyPIStats returned no daily download rows') + if (Number.isFinite(recentTotal) && summed > 0 && recentTotal !== summed) { + warnings.push('PyPIStats recent aggregate differs from summed daily rows; using recent aggregate for total') + } + + return { + source: { + kind: 'pypistats', + urls: [ + `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/overall?mirrors=false`, + `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/recent?period=${period}`, + ], + }, + daily: current, + totals: { + downloads: total, + days: current.length, + }, + comparison: compareTotals(total, previous.length === periodDays(period) ? sumDownloads(previous) : null), + warnings, + } +} + +export function buildMemorySearchQuery(registry, packageName, period) { + return `${SKILL_MARKER} registry:${registry} package:${packageName} period:${period}` +} + +function canonicalPackageName(registry, value) { + const name = String(value ?? '').trim() + return registry === 'npm' ? name : canonicalPypiPackageName(name) +} + +function snapshotsMatchRequest(snapshot, registry, packageName, period) { + if (!isRecord(snapshot)) return false + return ( + normalizeToken(snapshot.registry) === registry && + canonicalPackageName(registry, snapshot.packageName) === canonicalPackageName(registry, packageName) && + normalizeToken(snapshot.period) === period + ) +} + +function snapshotTimestamp(snapshot) { + const time = Date.parse(String(snapshot?.fetchedAt ?? '')) + return Number.isFinite(time) ? time : 0 +} + +function extractSnapshotFromContent(content) { + const text = String(content ?? '') + const marker = 'snapshot-json:' + const markerIndex = text.indexOf(marker) + if (markerIndex < 0) return null + const jsonStart = text.indexOf('{', markerIndex + marker.length) + if (jsonStart < 0) return null + const jsonEnd = findJsonEnd(text, jsonStart) + if (jsonEnd === null) return null + try { + const parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1)) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +function parseJsonLoose(raw) { + const text = String(raw ?? '').trim() + if (!text) return null + try { + return JSON.parse(text) + } catch { + for (let start = 0; start < text.length; start += 1) { + const ch = text[start] + if (ch !== '[' && ch !== '{') continue + const end = findJsonEnd(text, start) + if (end === null) continue + try { + return JSON.parse(text.slice(start, end + 1)) + } catch { + // Keep scanning in case log lines precede the JSON payload. + } + } + throw new Error('Invalid JSON from PowerMem search') + } +} + +function findJsonEnd(text, start) { + const first = text[start] + const stack = [first === '[' ? ']' : '}'] + let inString = false + let escaped = false + + for (let index = start + 1; index < text.length; index += 1) { + const ch = text[index] + if (inString) { + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } + continue + } + if (ch === '"') { + inString = true + } else if (ch === '[') { + stack.push(']') + } else if (ch === '{') { + stack.push('}') + } else if (ch === ']' || ch === '}') { + if (stack.pop() !== ch) return null + if (stack.length === 0) return index + } + } + return null +} + +function memoryRowContent(row) { + if (typeof row === 'string') return row + if (!isRecord(row)) return '' + if (typeof row.content === 'string') return row.content + if (typeof row.text === 'string') return row.text + if (typeof row.memory === 'string') return row.memory + if (isRecord(row.memory)) { + if (typeof row.memory.content === 'string') return row.memory.content + if (typeof row.memory.text === 'string') return row.memory.text + } + return '' +} + +function normalizeMemoryHits(raw, { registry, packageName, period }) { + const rows = Array.isArray(raw) + ? raw + : Array.isArray(raw?.hits) + ? raw.hits + : Array.isArray(raw?.memories) + ? raw.memories + : Array.isArray(raw?.results) + ? raw.results + : [] + const extracted = rows + .map((row) => extractSnapshotFromContent(memoryRowContent(row))) + .filter((snapshot) => isRecord(snapshot) && Number.isFinite(Number(snapshot.totalDownloads))) + const snapshots = extracted + .filter((snapshot) => snapshotsMatchRequest(snapshot, registry, packageName, period)) + .sort((a, b) => snapshotTimestamp(b) - snapshotTimestamp(a)) + return { + snapshots, + ignored: extracted.length - snapshots.length, + } +} + +export function defaultRunOpenclaw(args) { + const result = spawnSync('openclaw', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim() + if (result.error) throw result.error + if ((result.status ?? 0) !== 0) { + throw new Error(output || `openclaw exited with status ${result.status}`) + } + return output +} + +export function loadPreviousSnapshots({ registry, packageName, period, runOpenclaw = defaultRunOpenclaw }) { + const query = buildMemorySearchQuery(registry, packageName, period) + try { + const raw = runOpenclaw(['ltm', 'search', '--json', '--query', query]) + const normalized = normalizeMemoryHits(parseJsonLoose(raw), { registry, packageName, period }) + return { + query, + snapshots: normalized.snapshots, + diagnostics: { + ignored: normalized.ignored, + warning: null, + }, + } + } catch (error) { + return { + query, + snapshots: [], + diagnostics: { + ignored: 0, + warning: `PowerMem recall unavailable: ${error instanceof Error ? error.message : String(error)}`, + }, + } + } +} + +export function buildMemoryContent(result) { + const snapshot = { + registry: result.registry, + packageName: result.packageName, + period: result.period, + fetchedAt: result.fetchedAt, + totalDownloads: result.totals.downloads, + trend: result.trend, + warnings: result.warnings, + } + return [ + buildMemorySearchQuery(result.registry, result.packageName, result.period), + `summary: ${result.packageName} had ${result.totals.downloads.toLocaleString('en-US')} ${result.period} downloads; ${result.trend.text}`, + `snapshot-json: ${JSON.stringify(snapshot)}`, + ].join('\n') +} + +export function saveSnapshotToMemory(result, runOpenclaw = defaultRunOpenclaw) { + try { + runOpenclaw(['ltm', 'add', '--json', '--', buildMemoryContent(result)]) + return null + } catch (error) { + return `PowerMem save unavailable: ${error instanceof Error ? error.message : String(error)}` + } +} + +async function fetchCurrentWindow({ registry, packageName, period, fetchImpl }) { + if (registry === 'npm') { + const url = `https://api.npmjs.org/downloads/range/last-${period}/${encodeURIComponent(packageName)}` + return normalizeNpmPayload(packageName, period, await fetchJson(url, fetchImpl)) + } + + const overallUrl = `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/overall?mirrors=false` + const recentUrl = `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/recent?period=${period}` + const [overall, recent] = await Promise.all([ + fetchJson(overallUrl, fetchImpl), + fetchJson(recentUrl, fetchImpl), + ]) + return normalizePypiPayload(packageName, period, overall, recent) +} + +export async function analyzePackage({ + registry, + packageName, + period, + cachePath = DEFAULT_CACHE_DIR, + maxAgeMs = DEFAULT_MAX_AGE_MS, + refresh = false, + loadMemory = false, + saveMemory = false, + historyLimit = 6, + fetchImpl = fetch, + runOpenclaw = defaultRunOpenclaw, + now = () => new Date(), +}) { + const fetchedAt = now().toISOString() + const memory = loadMemory ? loadPreviousSnapshots({ registry, packageName, period, runOpenclaw }) : null + + const filePath = cacheFilePath(cachePath, registry, packageName, period) + const cached = refresh || saveMemory ? null : readCache(filePath, maxAgeMs, now().getTime()) + const current = cached ?? await fetchCurrentWindow({ registry, packageName, period, fetchImpl }) + current.fromCache = Boolean(cached) + if (!cached) { + writeCache(filePath, current) + } + + const priorSnapshot = memory?.snapshots?.[0] ?? null + const history = buildHistory(memory?.snapshots ?? [], current, fetchedAt, historyLimit) + const trend = buildTrend(history, { ...current.comparison, period }) + const periodColumns = buildPeriodColumns(period, history, current.comparison, current.totals.downloads) + const result = { + registry, + packageName, + period, + fetchedAt, + source: current.source, + cache: { + fromCache: Boolean(cached), + path: filePath, + }, + totals: current.totals, + daily: current.daily, + comparison: current.comparison, + priorMemory: priorSnapshot, + history, + periodColumns, + trend, + warnings: [...current.warnings], + diagnostics: { + memory: { + query: memory?.query ?? null, + snapshotsLoaded: memory?.snapshots?.length ?? 0, + ignoredSnapshots: memory?.diagnostics?.ignored ?? 0, + warning: memory?.diagnostics?.warning ?? null, + saveWarning: null, + }, + }, + } + + if (saveMemory) { + const warning = saveSnapshotToMemory(result, runOpenclaw) + if (warning) result.diagnostics.memory.saveWarning = warning + } + + return result +} + +export async function trackDownloads(options) { + const results = [] + for (const packageName of options.packages) { + results.push(await analyzePackage({ ...options, packageName })) + } + return { + registry: options.registry, + packages: options.packages, + period: options.period, + fetchedAt: new Date().toISOString(), + results, + warnings: results.flatMap((result) => result.warnings.map((warning) => `${result.packageName}: ${warning}`)), + } +} + +export function summarizeResults(payload) { + const lines = [ + `# Package Download Tracker (${payload.registry}, ${payload.period})`, + '', + `| Package | ${payload.results[0]?.periodColumns?.[0]?.label ?? `Current ${periodNoun(payload.period)}`} | ${payload.results[0]?.periodColumns?.[1]?.label ?? `Previous ${periodNoun(payload.period)}`} | Trend |`, + '|---|---:|---:|---|', + ] + + for (const result of payload.results) { + const previousDownloads = result.periodColumns?.[1]?.downloads + lines.push(`| ${result.packageName} | ${formatDownloads(result.periodColumns?.[0]?.downloads)} | ${formatDownloads(previousDownloads)} | ${displayTrendText(result)} |`) + } + + lines.push('') + + for (const result of payload.results) { + lines.push(`## ${result.packageName}`) + if (result.history.length > 1) { + lines.push(`- Observations: ${result.history.length} periods (${formatHistory(result.history)})`) + lines.push(`- Recent trend: ${displayTrendText(result)}`) + if (result.trend.overallDelta?.text) { + lines.push(`- Longer trend: ${result.trend.overallDelta.text}`) + } + } else { + lines.push(`- Trend: ${displayTrendText(result)}`) + } + for (const warning of result.warnings) { + lines.push(`- Warning: ${warning}`) + } + lines.push('') + } + + return lines.join('\n').trimEnd() +} diff --git a/bundled-skills/package-download-tracker/scripts/track-downloads.mjs b/bundled-skills/package-download-tracker/scripts/track-downloads.mjs new file mode 100644 index 00000000..e1a8eb83 --- /dev/null +++ b/bundled-skills/package-download-tracker/scripts/track-downloads.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { + parseArgs, + summarizeResults, + trackDownloads, +} from './common.mjs' + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const payload = await trackDownloads(options) + if (options.summary) { + process.stdout.write(`${summarizeResults(payload)}\n`) + return + } + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +}) diff --git a/package.json b/package.json index 39c1c4e7..15193750 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "bundled-skills/clawprobe-cost-digest/", "bundled-skills/ernie-image/", "bundled-skills/models-dev/", + "bundled-skills/package-download-tracker/", "bundled-skills/paddleocr-doc-parsing/", "plugins/memory-clawmaster-powermem/", "plugins/openclaw-ernie-image/", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index dd26732e..b3700ca1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -12,6 +12,7 @@ const ERNIE_IMAGE_PLUGIN_ROOT = path.resolve(__dirname, '../../../plugins/opencl const CONTENT_DRAFT_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/content-draft') const ERNIE_IMAGE_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/ernie-image') const MODELS_DEV_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/models-dev') +const PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/package-download-tracker') const PADDLEOCR_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/paddleocr-doc-parsing') if (fs.existsSync(path.join(CLAWPROBE_COST_DIGEST_SKILL_ROOT, 'SKILL.md'))) { @@ -29,6 +30,9 @@ if (fs.existsSync(path.join(ERNIE_IMAGE_SKILL_ROOT, 'SKILL.md'))) { if (fs.existsSync(path.join(MODELS_DEV_SKILL_ROOT, 'SKILL.md'))) { process.env.CLAWMASTER_BUNDLED_MODELS_DEV_SKILL_ROOT = MODELS_DEV_SKILL_ROOT } +if (fs.existsSync(path.join(PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT, 'SKILL.md'))) { + process.env.CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT = PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT +} if (fs.existsSync(path.join(PADDLEOCR_SKILL_ROOT, 'SKILL.md'))) { process.env.CLAWMASTER_BUNDLED_PADDLEOCR_DOC_PARSING_SKILL_ROOT = PADDLEOCR_SKILL_ROOT } diff --git a/packages/backend/src/services/bundledSkills.test.ts b/packages/backend/src/services/bundledSkills.test.ts index afae57a3..2d3c57c9 100644 --- a/packages/backend/src/services/bundledSkills.test.ts +++ b/packages/backend/src/services/bundledSkills.test.ts @@ -16,6 +16,8 @@ test('isBundledSkillSlug recognizes bundled skill ids case-insensitively', () => assert.equal(isBundledSkillSlug('ERNIE-IMAGE'), true) assert.equal(isBundledSkillSlug('models-dev'), true) assert.equal(isBundledSkillSlug('MODELS-DEV'), true) + assert.equal(isBundledSkillSlug('package-download-tracker'), true) + assert.equal(isBundledSkillSlug('PACKAGE-DOWNLOAD-TRACKER'), true) assert.equal(isBundledSkillSlug('paddleocr-doc-parsing'), true) assert.equal(isBundledSkillSlug('PADDLEOCR-DOC-PARSING'), true) assert.equal(isBundledSkillSlug('image-generate'), false) @@ -188,6 +190,35 @@ test('installBundledSkill copies the bundled models.dev skill into the active wo ) }) +test('installBundledSkill copies the bundled package download tracker skill into the active workspace', () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-src-')) + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-data-')) + fs.mkdirSync(path.join(sourceRoot, 'scripts'), { recursive: true }) + fs.writeFileSync(path.join(sourceRoot, 'SKILL.md'), '# Package Download Tracker\n', 'utf8') + fs.writeFileSync(path.join(sourceRoot, '_meta.json'), '{"slug":"package-download-tracker","version":"1.0.0"}\n', 'utf8') + fs.writeFileSync(path.join(sourceRoot, 'scripts', 'track-downloads.mjs'), 'console.log("track")\n', 'utf8') + + const result = installBundledSkill('package-download-tracker', { + dataDir, + env: { + ...process.env, + CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT: sourceRoot, + }, + }) + + const installDir = path.join(dataDir, 'workspace', 'skills', 'package-download-tracker') + assert.equal(result.installDir, installDir) + assert.equal(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf8'), '# Package Download Tracker\n') + assert.equal( + fs.readFileSync(path.join(installDir, '_meta.json'), 'utf8'), + '{"slug":"package-download-tracker","version":"1.0.0"}\n', + ) + assert.equal( + fs.readFileSync(path.join(installDir, 'scripts', 'track-downloads.mjs'), 'utf8'), + 'console.log("track")\n', + ) +}) + test('installBundledSkill uses WSL copy commands for Linux runtime data dirs on Windows', () => { const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-src-')) fs.mkdirSync(path.join(sourceRoot, 'scripts'), { recursive: true }) @@ -325,7 +356,7 @@ test('syncInstalledBundledSkills refreshes WSL-installed bundled skills with a W }) assert.deepEqual(synced, ['content-draft']) - assert.equal(wslScripts.filter(({ script }) => script.startsWith('test -e ')).length, 5) + assert.equal(wslScripts.filter(({ script }) => script.startsWith('test -e ')).length, 6) assert.equal(wslScripts.filter(({ script }) => script.startsWith('cat ')).length, 1) const copyScript = wslScripts.find(({ script }) => /cp -a/.test(script)) assert.ok(copyScript) @@ -357,6 +388,19 @@ test('bundled models.dev skill explicitly instructs agents to read the skill and assert.match(skillBody, /Then use `exec` to run the bundled Node scripts with `node`\./) }) +test('bundled package download tracker skill explicitly instructs agents to read the skill and exec the script', () => { + const skillPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../bundled-skills/package-download-tracker/SKILL.md', + ) + const skillBody = fs.readFileSync(skillPath, 'utf8') + + assert.match(skillBody, /Do not call `package-download-tracker` as if it were a built-in tool\./) + assert.match(skillBody, /First use `read` to load this `SKILL\.md`\./) + assert.match(skillBody, /Then use `exec` to run the bundled Node script with `node`\./) + assert.match(skillBody, /--load-memory --save-memory/) +}) + test('bundled PaddleOCR skill explicitly instructs agents to read the skill and exec the script', () => { const skillPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/packages/backend/src/services/bundledSkills.ts b/packages/backend/src/services/bundledSkills.ts index bc9fe608..f3881fd5 100644 --- a/packages/backend/src/services/bundledSkills.ts +++ b/packages/backend/src/services/bundledSkills.ts @@ -25,6 +25,10 @@ const BUNDLED_SKILLS = { dirName: 'models-dev', envKey: 'CLAWMASTER_BUNDLED_MODELS_DEV_SKILL_ROOT', }, + 'package-download-tracker': { + dirName: 'package-download-tracker', + envKey: 'CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT', + }, 'paddleocr-doc-parsing': { dirName: 'paddleocr-doc-parsing', envKey: 'CLAWMASTER_BUNDLED_PADDLEOCR_DOC_PARSING_SKILL_ROOT', diff --git a/packages/backend/src/services/packageDownloadTrackerSkill.test.ts b/packages/backend/src/services/packageDownloadTrackerSkill.test.ts new file mode 100644 index 00000000..4f973837 --- /dev/null +++ b/packages/backend/src/services/packageDownloadTrackerSkill.test.ts @@ -0,0 +1,573 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + analyzePackage, + buildMemoryContent, + buildMemorySearchQuery, + normalizePackageName, + parseArgs, + summarizeResults, + trackDownloads, +} from '../../../../bundled-skills/package-download-tracker/scripts/common.mjs' + +function mockResponse(payload: unknown) { + return { + ok: true, + status: 200, + async json() { + return payload + }, + } +} + +function npmPayload(downloads: number[]) { + return { + downloads: downloads.map((value, index) => ({ + day: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: value, + })), + } +} + +test('package-download-tracker parses repeatable package flags and scoped npm names', () => { + const parsed = parseArgs([ + '--registry', + 'npm', + '--package', + '@types/node', + '--packages', + 'react, vite', + '--period', + 'month', + '--load-memory', + '--save-memory', + '--history-limit', + '8', + ]) + + assert.equal(parsed.registry, 'npm') + assert.equal(parsed.period, 'month') + assert.deepEqual(parsed.packages, ['@types/node', 'react', 'vite']) + assert.equal(parsed.loadMemory, true) + assert.equal(parsed.saveMemory, true) + assert.equal(parsed.historyLimit, 8) +}) + +test('package-download-tracker validates PyPI package names', () => { + assert.equal(normalizePackageName('pypi', 'fastapi'), 'fastapi') + assert.equal(normalizePackageName('pypi', 'google-cloud-storage'), 'google-cloud-storage') + assert.equal(normalizePackageName('pypi', 'Power_Mem'), 'power-mem') + assert.equal(normalizePackageName('pypi', 'google.cloud_storage'), 'google-cloud-storage') + assert.throws(() => normalizePackageName('pypi', '../secret'), /Invalid PyPI package name/) +}) + +test('package-download-tracker builds stable PowerMem content and search query', () => { + const result = { + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-28T00:00:00.000Z', + totals: { downloads: 700, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'up 16.7% versus previous observation' }, + warnings: [], + } + + assert.equal( + buildMemorySearchQuery('npm', 'react', 'week'), + 'package-download-tracker registry:npm package:react period:week', + ) + const content = buildMemoryContent(result) + assert.match(content, /package-download-tracker registry:npm package:react period:week/) + assert.match(content, /snapshot-json: \{"registry":"npm","packageName":"react"/) +}) + +test('package-download-tracker reuses previous memory before fetching only the current window', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const calls: string[] = [] + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const runOpenclaw = (args: string[]) => { + calls.push(`openclaw ${args.join(' ')}`) + assert.deepEqual(args, [ + 'ltm', + 'search', + '--json', + '--query', + 'package-download-tracker registry:npm package:react period:week', + ]) + return JSON.stringify([{ content: memoryContent }]) + } + + const fetchUrls: string[] = [] + const fetchImpl = async (url: string) => { + fetchUrls.push(url) + return mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + } + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'react', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(calls.length, 1) + assert.equal(fetchUrls.length, 1) + assert.equal(fetchUrls[0], 'https://api.npmjs.org/downloads/range/last-week/react') + assert.equal(result.priorMemory.totalDownloads, 600) + assert.equal(result.trend.basis, 'history') + assert.equal(result.history.length, 2) + assert.deepEqual( + result.periodColumns.map((column) => [column.label, column.downloads]), + [['Current week', 700], ['Previous week', 600]], + ) + assert.match(result.trend.text, /previous observation/) + assert.equal(result.totals.downloads, 700) +}) + +test('package-download-tracker builds trend analysis across multiple historical observations', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const snapshots = [ + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-07T00:00:00.000Z', + totals: { downloads: 300, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older' }, + warnings: [], + }), + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-14T00:00:00.000Z', + totals: { downloads: 450, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'middle' }, + warnings: [], + }), + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'newer' }, + warnings: [], + }), + ] + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => JSON.stringify(snapshots.map((content) => ({ content }))) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + cachePath, + loadMemory: true, + historyLimit: 4, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.trend.basis, 'history') + assert.equal(result.history.length, 4) + assert.deepEqual( + result.history.map((point) => point.downloads), + [300, 450, 600, 700], + ) + assert.match(result.trend.text, /up 16\.7% versus previous observation/) + assert.equal(result.trend.overallDelta?.direction, 'up') + assert.match(result.trend.overallDelta?.text ?? '', /up 133\.3% across 4 observations/) +}) + +test('package-download-tracker keeps one history observation per day', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const olderSameDay = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 400, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older same day' }, + warnings: [], + }) + const newerSameDay = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-21T12:00:00.000Z', + totals: { downloads: 500, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'newer same day' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => JSON.stringify([ + { content: olderSameDay }, + { content: newerSameDay }, + ]) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.deepEqual( + result.history.map((point) => point.downloads), + [500, 700], + ) + assert.match(result.trend.text, /up 40\.0% versus previous observation/) +}) + +test('package-download-tracker tolerates PowerMem log preambles before JSON search results', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 500, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => `[plugins] memory-clawmaster-powermem loaded\n${JSON.stringify([{ content: memoryContent }])}\n` + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'react', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.totalDownloads, 500) + assert.equal(result.trend.basis, 'history') + assert.deepEqual(result.warnings, []) +}) + +test('package-download-tracker ignores PowerMem snapshots for other packages', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const wrongMemory = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 131_000_000, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'wrong package' }, + warnings: [], + }) + const rightMemory = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-20T00:00:00.000Z', + totals: { downloads: 400, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'right package' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([67, 67, 67, 67, 67, 67, 67])) + const runOpenclaw = () => JSON.stringify([ + { content: wrongMemory }, + { content: rightMemory }, + ]) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.packageName, 'clawmaster') + assert.equal(result.priorMemory.totalDownloads, 400) + assert.equal(result.trend.basis, 'history') + assert.match(result.trend.text, /up 17\.3%/) + assert.deepEqual(result.warnings, []) + assert.equal(result.diagnostics.memory.ignoredSnapshots, 1) + assert.doesNotMatch(result.trend.text, /131/) +}) + +test('package-download-tracker uses the newest exact PowerMem snapshot', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const olderMemory = buildMemoryContent({ + registry: 'pypi', + packageName: 'PowerMem', + period: 'week', + fetchedAt: '2026-04-20T00:00:00.000Z', + totals: { downloads: 700, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older' }, + warnings: [], + }) + const newerMemory = buildMemoryContent({ + registry: 'pypi', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-27T00:00:00.000Z', + totals: { downloads: 900, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'newer' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse({ + data: Array.from({ length: 14 }, (_, index) => ({ + date: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: index < 7 ? 100 : 130, + })), + }) + const recentFetchImpl = async (url: string) => { + if (String(url).includes('/recent')) { + return mockResponse({ data: { last_week: 910 } }) + } + return fetchImpl() + } + const runOpenclaw = () => JSON.stringify([ + { content: olderMemory }, + { content: newerMemory }, + ]) + + const result = await analyzePackage({ + registry: 'pypi', + packageName: 'powermem', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl: recentFetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.fetchedAt, '2026-04-27T00:00:00.000Z') + assert.equal(result.priorMemory.totalDownloads, 900) + assert.deepEqual( + result.periodColumns.map((column) => [column.label, column.downloads]), + [['Current week', 910], ['Previous week', 900]], + ) + assert.match(result.trend.text, /up 1\.1%/) +}) + +test('package-download-tracker canonicalizes PyPI names for cache and memory reuse', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'pypi', + packageName: 'power-mem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 900, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchUrls: string[] = [] + const fetchImpl = async (url: string) => { + fetchUrls.push(url) + if (String(url).includes('/recent')) { + return mockResponse({ data: { last_week: 1_000 } }) + } + return mockResponse({ + data: Array.from({ length: 7 }, (_, index) => ({ + date: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: 100, + })), + }) + } + const runOpenclaw = (args: string[]) => { + assert.deepEqual(args, [ + 'ltm', + 'search', + '--json', + '--query', + 'package-download-tracker registry:pypi package:power-mem period:week', + ]) + return JSON.stringify([{ content: memoryContent }]) + } + + const parsed = parseArgs(['--registry', 'pypi', '--package', 'Power_Mem', '--period', 'week']) + assert.deepEqual(parsed.packages, ['power-mem']) + + const result = await analyzePackage({ + registry: 'pypi', + packageName: parsed.packages[0]!, + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.packageName, 'power-mem') + assert.equal(result.periodColumns[1]?.downloads, 900) + assert.match(result.trend.text, /up 11\.1%/) + assert.ok(fetchUrls.every((url) => String(url).includes('/power-mem/'))) +}) + +test('package-download-tracker summary renders current and previous period columns', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const payload = await trackDownloads({ + registry: 'npm', + packages: ['powermem'], + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw: () => JSON.stringify([{ content: memoryContent }]), + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + const summary = summarizeResults(payload) + assert.match(summary, /\| Package \| Current week \| Previous week \| Trend \|/) + assert.match(summary, /\| powermem \| 700 \| 600 \| up 16\.7% versus previous observation/) +}) + +test('package-download-tracker summary does not show percentage changes without a previous period value', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const payload = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + payload.results[0]!.trend.text = 'down -22.0% versus previous week' + + const summary = summarizeResults(payload) + assert.match(summary, /\| clawmaster \| 700 \| n\/a \| No previous week data available\. \|/) + assert.doesNotMatch(summary, /-22\.0%/) +}) + +test('package-download-tracker keeps PowerMem save failures out of user-facing warnings', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const fetchImpl = async () => mockResponse(npmPayload([10, 10, 10, 10, 10, 10, 10])) + const runOpenclaw = (args: string[]) => { + if (args[0] === 'ltm' && args[1] === 'search') return JSON.stringify([]) + throw new Error('PowerMem offline') + } + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + saveMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.deepEqual(result.warnings, []) + assert.match(result.diagnostics.memory.saveWarning ?? '', /PowerMem save unavailable/) +}) + +test('package-download-tracker uses cache for repeat requests', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + let fetchCount = 0 + const fetchImpl = async () => { + fetchCount += 1 + return mockResponse(npmPayload([10, 20, 30, 40, 50, 60, 70])) + } + + const first = await trackDownloads({ + registry: 'npm', + packages: ['react'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + const second = await trackDownloads({ + registry: 'npm', + packages: ['react'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(fetchCount, 1) + assert.equal(first.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.cache.fromCache, true) + assert.equal(second.results[0]?.totals.downloads, 280) +}) + +test('package-download-tracker bypasses cache when saving a fresh memory observation', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + let fetchCount = 0 + const fetchImpl = async () => { + fetchCount += 1 + return mockResponse(npmPayload(fetchCount === 1 + ? [10, 10, 10, 10, 10, 10, 10] + : [20, 20, 20, 20, 20, 20, 20])) + } + const runOpenclaw = (args: string[]) => { + if (args[0] === 'ltm' && args[1] === 'search') return JSON.stringify([]) + if (args[0] === 'ltm' && args[1] === 'add') return JSON.stringify({ ok: true }) + throw new Error(`unexpected openclaw args: ${args.join(' ')}`) + } + + const first = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T08:01:00.000Z'), + }) + const second = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + loadMemory: true, + saveMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-29T07:59:00.000Z'), + }) + + assert.equal(fetchCount, 2) + assert.equal(first.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.totals.downloads, 140) +}) diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index 5af697f8..7f37b9df 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -337,6 +337,7 @@ "skills.catalog.ernieImage.desc": "ERNIE-Image-specific guide for prompt shaping and choosing Baidu image arguments like size, seed, use_pe, steps, and guidance scale", "skills.catalog.modelsDev.desc": "General-purpose models.dev lookup skill for cached pricing, context limits, capability flags, and provider comparisons", "skills.catalog.clawprobeCostDigest.desc": "Bundled ClawProbe digest skill that refreshes models.dev pricing and generates day, week, or month spend summaries with top sessions and models", + "skills.catalog.packageDownloadTracker.desc": "Bundled npm and PyPI download tracker that uses stored observations for weekly or monthly trend analysis", "skills.catalog.imageGenerate.desc": "Guided image generation workflow skill for turning goals into prompts and runnable image tasks", "skills.catalog.clawvet.desc": "Skill security vetting tool that scans for injection attacks and supply chain risks", "skills.catalog.ontoskills.desc": "OWL 2 skill compiler that transforms skills into validated semantic web ontologies", diff --git a/packages/web/src/i18n/ja.json b/packages/web/src/i18n/ja.json index 6cc6e8ec..13fd6257 100644 --- a/packages/web/src/i18n/ja.json +++ b/packages/web/src/i18n/ja.json @@ -337,6 +337,7 @@ "skills.catalog.ernieImage.desc": "ERNIE-Image 専用ガイド。プロンプト整理と、size・seed・use_pe・steps・guidance scale などの Baidu 画像引数選びを支援します。", "skills.catalog.modelsDev.desc": "価格、コンテキスト上限、能力フラグ、Provider 比較をキャッシュ付きで引ける汎用 models.dev スキル", "skills.catalog.clawprobeCostDigest.desc": "bundled の ClawProbe コストダイジェストスキル。models.dev 価格を更新し、日次・週次・月次の支出要約と高コスト session / model を生成します。", + "skills.catalog.packageDownloadTracker.desc": "npm / PyPI downloads を追跡し、保存済み observations から週次・月次 trend を分析する bundled スキル", "skills.catalog.imageGenerate.desc": "目的をプロンプトと実行可能な画像生成タスクに落とし込むワークフロースキル", "skills.catalog.clawvet.desc": "スキルセキュリティ検査ツール、インジェクション攻撃とサプライチェーンリスクを検出", "skills.catalog.ontoskills.desc": "OWL 2 スキルコンパイラ、スキルをセマンティック Web オントロジーに変換・検証", diff --git a/packages/web/src/i18n/zh.json b/packages/web/src/i18n/zh.json index 70d9af7c..3146ae61 100644 --- a/packages/web/src/i18n/zh.json +++ b/packages/web/src/i18n/zh.json @@ -337,6 +337,7 @@ "skills.catalog.ernieImage.desc": "面向 ERNIE-Image 的专项技能,帮助整理提示词并选择 size、seed、use_pe、steps、guidance scale 等百度绘图参数", "skills.catalog.modelsDev.desc": "通用的 models.dev 查询技能,可缓存价格、上下文上限、能力标记和 provider 对比信息", "skills.catalog.clawprobeCostDigest.desc": "内置 ClawProbe 成本摘要技能,可刷新 models.dev 定价并生成按日、周、月的花费摘要,附带高成本 session 和模型排行", + "skills.catalog.packageDownloadTracker.desc": "内置 npm / PyPI 下载追踪技能,可使用已存观察做周/月趋势分析", "skills.catalog.imageGenerate.desc": "图像生成工作流技能,把目标整理成提示词和可执行的出图任务", "skills.catalog.clawvet.desc": "技能安全审查工具,扫描注入攻击和供应链风险", "skills.catalog.ontoskills.desc": "OWL 2 技能编译器,将技能转换为语义 Web 本体并验证", diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index 143956a8..20783a8f 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -454,6 +454,7 @@ export default { "skills.catalog.ernieImage.desc": "ERNIE-Image-specific guide for prompt shaping and choosing Baidu image arguments like size, seed, use_pe, steps, and guidance scale", "skills.catalog.modelsDev.desc": "General-purpose models.dev lookup skill for cached pricing, context limits, capability flags, and provider comparisons", "skills.catalog.clawprobeCostDigest.desc": "Bundled ClawProbe digest skill that refreshes models.dev pricing and generates day, week, or month spend summaries with top sessions and models", + "skills.catalog.packageDownloadTracker.desc": "Bundled npm and PyPI download tracker that uses stored observations for weekly or monthly trend analysis", "skills.catalog.imageGenerate.desc": "Guided image generation workflow skill for turning goals into prompts and runnable image tasks", "skills.catalog.clawvet.desc": "Skill security vetting tool that scans for injection attacks and supply chain risks", "skills.catalog.ontoskills.desc": "OWL 2 skill compiler that transforms skills into validated semantic web ontologies", @@ -1424,6 +1425,12 @@ export default { "observe.digestDraft.month.name": "Monthly Cost Digest", "observe.digestDraft.month.description": "ClawMaster scheduled digest for the last 30 days via the bundled clawprobe-cost-digest skill.", "observe.digestDraft.month.prompt": "Use the installed clawprobe-cost-digest skill to generate the monthly OpenClaw cost digest for the last 30 days. Read the skill, run `node ${SKILL_DIR}/scripts/generate-digest.mjs --period month --summary`, and return only the generated markdown summary. Do not invent numbers or add extra commentary.", + "cron.packageDownloadsDraft.week.name": "Weekly Package Download Tracker", + "cron.packageDownloadsDraft.week.description": "Daily package download trend check using the bundled package-download-tracker skill.", + "cron.packageDownloadsDraft.week.prompt": "Use the installed package-download-tracker skill to track weekly package download trends across recent historical observations. Edit the sample package names in this prompt before saving: npm packages `clawmaster, powermem`; PyPI packages `powermem`. Internally prefer stored history before broad registry lookups and keep the history updated. In the final answer, use a table with at least two week columns, such as Current week and Previous week, then summarize recent and longer-term trends from the available observations. If a previous-week value is unavailable, mark the change as unavailable too; only show a percentage when the previous-week column contains the numeric baseline. Call out only npm/PyPI registry or data-quality issues, and do not mention memory, stored snapshots, cache, script commands, or other internal mechanics. Do not invent numbers.", + "cron.packageDownloadsDraft.month.name": "Monthly Package Download Tracker", + "cron.packageDownloadsDraft.month.description": "Daily package download trend check using the bundled package-download-tracker skill.", + "cron.packageDownloadsDraft.month.prompt": "Use the installed package-download-tracker skill to track monthly package download trends across recent historical observations. Edit the sample package names in this prompt before saving: npm packages `clawmaster, powermem`; PyPI packages `powermem`. Internally prefer stored history before broad registry lookups and keep the history updated. In the final answer, use a table with at least two month columns, such as Current month and Previous month, then summarize recent and longer-term trends from the available observations. If a previous-month value is unavailable, mark the change as unavailable too; only show a percentage when the previous-month column contains the numeric baseline. Call out only npm/PyPI registry or data-quality issues, and do not mention memory, stored snapshots, cache, script commands, or other internal mechanics. Do not invent numbers.", "channelsPage.loading": "Loading channels...", "channelsPage.loadFailed": "Load failed:", "channelsPage.noConfig": "No config data", @@ -1939,6 +1946,7 @@ export default { "cron.schedulePreviewInvalidAt": "Use a valid ISO date and time.", "cron.schedulePreviewInvalidTimezone": "Timezone {{value}} is invalid. Use a valid IANA timezone name.", "cron.templateLoadedCostDigest": "Loaded the {{period}} cost digest template. Review the prompt, choose delivery, and save the job.", + "cron.templateLoadedPackageDownloads": "Loaded the {{period}} package download tracker template. Edit package names in the prompt, choose delivery, and save the job.", "cron.presetWeekdayMorning": "Weekdays 08:00", "cron.presetDailyMorning": "Daily 09:00", "cron.presetHourly": "Hourly", diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index 3bee87de..10e3cab2 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -454,6 +454,7 @@ export default { "skills.catalog.ernieImage.desc": "ERNIE-Image 専用ガイド。プロンプト整理と、size・seed・use_pe・steps・guidance scale などの Baidu 画像引数選びを支援します。", "skills.catalog.modelsDev.desc": "価格、コンテキスト上限、能力フラグ、Provider 比較をキャッシュ付きで引ける汎用 models.dev スキル", "skills.catalog.clawprobeCostDigest.desc": "bundled の ClawProbe コストダイジェストスキル。models.dev 価格を更新し、日次・週次・月次の支出要約と高コスト session / model を生成します。", + "skills.catalog.packageDownloadTracker.desc": "npm / PyPI downloads を追跡し、保存済み observations から週次・月次 trend を分析する bundled スキル", "skills.catalog.imageGenerate.desc": "目的をプロンプトと実行可能な画像生成タスクに落とし込むワークフロースキル", "skills.catalog.clawvet.desc": "スキルセキュリティ検査ツール、インジェクション攻撃とサプライチェーンリスクを検出", "skills.catalog.ontoskills.desc": "OWL 2 スキルコンパイラ、スキルをセマンティック Web オントロジーに変換・検証", @@ -1424,6 +1425,12 @@ export default { "observe.digestDraft.month.name": "Monthly Cost Digest", "observe.digestDraft.month.description": "bundled の clawprobe-cost-digest スキルで直近 30 日間の定期コスト要約を生成します。", "observe.digestDraft.month.prompt": "インストール済みの clawprobe-cost-digest スキルを使って、直近 30 日間の OpenClaw 月次コストダイジェストを生成してください。まずスキルを読み、その後 `node ${SKILL_DIR}/scripts/generate-digest.mjs --period month --summary` を実行し、生成された markdown 要約だけを返してください。数値を作らず、余計な説明も足さないでください。", + "cron.packageDownloadsDraft.week.name": "Weekly Package Download Tracker", + "cron.packageDownloadsDraft.week.description": "bundled の package-download-tracker スキルで package download trend を毎日確認します。", + "cron.packageDownloadsDraft.week.prompt": "インストール済みの package-download-tracker スキルを使って、package の週次 download trend を最近の複数の履歴観測から追跡してください。保存前にこの prompt 内のサンプル package 名を編集してください: npm packages `clawmaster, powermem`; PyPI packages `powermem`。内部では保存済み履歴を優先して使い、過去 trend の再構成だけを目的に広い registry query を繰り返さず、履歴を更新してください。最終回答では Current week と Previous week など少なくとも 2 つの週列を持つ表を使い、利用できる観測から直近と長期の trend だけを要約してください。Previous week の値がない場合は change も unavailable とし、Previous week 列に計算元の数値がある場合だけ percentage を出してください。npm/PyPI registry または data quality の問題だけを伝え、memory、保存済み snapshot、cache、script command、その他の内部仕組みには触れないでください。数値を作らないでください。", + "cron.packageDownloadsDraft.month.name": "Monthly Package Download Tracker", + "cron.packageDownloadsDraft.month.description": "bundled の package-download-tracker スキルで package download trend を毎日確認します。", + "cron.packageDownloadsDraft.month.prompt": "インストール済みの package-download-tracker スキルを使って、package の月次 download trend を最近の複数の履歴観測から追跡してください。保存前にこの prompt 内のサンプル package 名を編集してください: npm packages `clawmaster, powermem`; PyPI packages `powermem`。内部では保存済み履歴を優先して使い、過去 trend の再構成だけを目的に広い registry query を繰り返さず、履歴を更新してください。最終回答では Current month と Previous month など少なくとも 2 つの月列を持つ表を使い、利用できる観測から直近と長期の trend だけを要約してください。Previous month の値がない場合は change も unavailable とし、Previous month 列に計算元の数値がある場合だけ percentage を出してください。npm/PyPI registry または data quality の問題だけを伝え、memory、保存済み snapshot、cache、script command、その他の内部仕組みには触れないでください。数値を作らないでください。", "channelsPage.loading": "チャンネル読み込み中...", "channelsPage.loadFailed": "読み込み失敗:", "channelsPage.noConfig": "設定データなし", @@ -1939,6 +1946,7 @@ export default { "cron.schedulePreviewInvalidAt": "有効な ISO 日時を指定してください。", "cron.schedulePreviewInvalidTimezone": "タイムゾーン {{value}} は無効です。有効な IANA タイムゾーン名を指定してください。", "cron.templateLoadedCostDigest": "{{period}}コストダイジェストのテンプレートを読み込みました。プロンプトと配信先を確認して保存してください。", + "cron.templateLoadedPackageDownloads": "{{period}} package download tracker テンプレートを読み込みました。prompt 内の package 名と配信先を確認して保存してください。", "cron.presetWeekdayMorning": "平日 08:00", "cron.presetDailyMorning": "毎日 09:00", "cron.presetHourly": "毎時", diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index 64a78a73..863215d9 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -454,6 +454,7 @@ export default { "skills.catalog.ernieImage.desc": "面向 ERNIE-Image 的专项技能,帮助整理提示词并选择 size、seed、use_pe、steps、guidance scale 等百度绘图参数", "skills.catalog.modelsDev.desc": "通用的 models.dev 查询技能,可缓存价格、上下文上限、能力标记和 provider 对比信息", "skills.catalog.clawprobeCostDigest.desc": "内置 ClawProbe 成本摘要技能,可刷新 models.dev 定价并生成按日、周、月的花费摘要,附带高成本 session 和模型排行", + "skills.catalog.packageDownloadTracker.desc": "内置 npm / PyPI 下载追踪技能,可使用已存观察做周/月趋势分析", "skills.catalog.imageGenerate.desc": "图像生成工作流技能,把目标整理成提示词和可执行的出图任务", "skills.catalog.clawvet.desc": "技能安全审查工具,扫描注入攻击和供应链风险", "skills.catalog.ontoskills.desc": "OWL 2 技能编译器,将技能转换为语义 Web 本体并验证", @@ -1424,6 +1425,12 @@ export default { "observe.digestDraft.month.name": "Monthly Cost Digest", "observe.digestDraft.month.description": "通过内置 clawprobe-cost-digest 技能生成最近 30 天的定时成本摘要。", "observe.digestDraft.month.prompt": "使用已安装的 clawprobe-cost-digest 技能生成最近 30 天的 OpenClaw 月成本摘要。先读取该技能,再运行 `node ${SKILL_DIR}/scripts/generate-digest.mjs --period month --summary`,最终只返回脚本生成的 markdown 摘要。不要编造数字,也不要额外扩写。", + "cron.packageDownloadsDraft.week.name": "Weekly Package Download Tracker", + "cron.packageDownloadsDraft.week.description": "通过内置 package-download-tracker 技能每日检查包下载趋势。", + "cron.packageDownloadsDraft.week.prompt": "使用已安装的 package-download-tracker 技能追踪包的周下载趋势,并基于最近多次历史观察分析变化。保存前先在这个提示词里修改示例包名:npm packages `clawmaster, powermem`;PyPI packages `powermem`。内部优先使用已存历史,避免为了旧趋势重复发起大范围 registry 查询,并保持历史更新。最终回答必须使用至少两个周维度列的表格,例如本周和上周,然后基于可用观察总结近期和较长期趋势;如果上周数据不可用,变化也必须标为不可用,只有上周列显示了用于计算的数字时才展示百分比变化;只说明 npm/PyPI registry 或数据质量问题,不要提 memory、已存快照、缓存、脚本命令或其他内部机制。不要编造数字。", + "cron.packageDownloadsDraft.month.name": "Monthly Package Download Tracker", + "cron.packageDownloadsDraft.month.description": "通过内置 package-download-tracker 技能每日检查包下载趋势。", + "cron.packageDownloadsDraft.month.prompt": "使用已安装的 package-download-tracker 技能追踪包的月下载趋势,并基于最近多次历史观察分析变化。保存前先在这个提示词里修改示例包名:npm packages `clawmaster, powermem`;PyPI packages `powermem`。内部优先使用已存历史,避免为了旧趋势重复发起大范围 registry 查询,并保持历史更新。最终回答必须使用至少两个月维度列的表格,例如本月和上月,然后基于可用观察总结近期和较长期趋势;如果上月数据不可用,变化也必须标为不可用,只有上月列显示了用于计算的数字时才展示百分比变化;只说明 npm/PyPI registry 或数据质量问题,不要提 memory、已存快照、缓存、脚本命令或其他内部机制。不要编造数字。", "channelsPage.loading": "加载通道中...", "channelsPage.loadFailed": "加载失败:", "channelsPage.noConfig": "无配置数据", @@ -1939,6 +1946,7 @@ export default { "cron.schedulePreviewInvalidAt": "请输入有效的 ISO 日期时间。", "cron.schedulePreviewInvalidTimezone": "时区 {{value}} 无效。请输入有效的 IANA 时区名称。", "cron.templateLoadedCostDigest": "已加载{{period}}成本摘要模板。检查提示词、配置投递方式后保存即可。", + "cron.templateLoadedPackageDownloads": "已加载{{period}}包下载追踪模板。请在提示词中修改包名、配置投递方式后保存。", "cron.presetWeekdayMorning": "工作日 08:00", "cron.presetDailyMorning": "每天 09:00", "cron.presetHourly": "每小时", diff --git a/packages/web/src/modules/cron/CronPage.tsx b/packages/web/src/modules/cron/CronPage.tsx index f2b5091f..61df7e4d 100644 --- a/packages/web/src/modules/cron/CronPage.tsx +++ b/packages/web/src/modules/cron/CronPage.tsx @@ -6,7 +6,12 @@ import { ExternalLink, Play, RefreshCw, SquarePen, TimerReset, Trash2 } from 'lu import { ActionBanner } from '@/shared/components/ActionBanner' import { ConfirmDialog } from '@/shared/components/ConfirmDialog' import { LoadingState } from '@/shared/components/LoadingState' -import { buildCostDigestDraft, isCostDigestPeriod } from '@/shared/cronCostDigests' +import { + buildCostDigestDraft, + buildPackageDownloadDraft, + isCostDigestPeriod, + isPackageDownloadPeriod, +} from '@/shared/cronCostDigests' import { getGatewayStatusResult } from '@/shared/adapters/gateway' import { getConfigResult } from '@/shared/adapters/openclaw' import { @@ -274,7 +279,7 @@ export default function CronPage() { const template = searchParams.get('template') const period = searchParams.get('period') - if (template !== 'cost-digest' || !isCostDigestPeriod(period)) { + if (template !== 'cost-digest' && template !== 'package-downloads') { return } @@ -282,16 +287,31 @@ export default function CronPage() { return } + const costDigestPeriod = isCostDigestPeriod(period) ? period : null + if (template === 'cost-digest' && !costDigestPeriod) { + return + } + const packageDownloadPeriod = isPackageDownloadPeriod(period) ? period : 'week' + templateApplied.current = true setEditorMode('create') setEditingJobId(null) - setDraft(buildCostDigestDraft(period, t)) + setDraft( + template === 'cost-digest' + ? buildCostDigestDraft(costDigestPeriod!, t) + : buildPackageDownloadDraft(packageDownloadPeriod, t), + ) setEditorError(null) setFeedback({ tone: 'info', - message: t('cron.templateLoadedCostDigest', { - period: t(`observe.period${period[0].toUpperCase()}${period.slice(1)}`), - }), + message: + template === 'cost-digest' + ? t('cron.templateLoadedCostDigest', { + period: t(`observe.period${costDigestPeriod![0].toUpperCase()}${costDigestPeriod!.slice(1)}`), + }) + : t('cron.templateLoadedPackageDownloads', { + period: t(`observe.period${packageDownloadPeriod[0].toUpperCase()}${packageDownloadPeriod.slice(1)}`), + }), }) }, [gatewayReady, gatewayResolved, searchParams, t]) diff --git a/packages/web/src/modules/cron/__tests__/CronPage.test.tsx b/packages/web/src/modules/cron/__tests__/CronPage.test.tsx index c20f3f8c..e1328df9 100644 --- a/packages/web/src/modules/cron/__tests__/CronPage.test.tsx +++ b/packages/web/src/modules/cron/__tests__/CronPage.test.tsx @@ -232,6 +232,38 @@ describe('CronPage', () => { ) }) + it('opens the create dialog with a prefilled package download tracker template', async () => { + renderPage(['/cron?template=package-downloads&period=week']) + + expect(await screen.findByRole('dialog', { name: 'Create Cron Job' })).toBeInTheDocument() + expect(screen.getByText('Loaded the Week package download tracker template. Edit package names in the prompt, choose delivery, and save the job.')).toBeInTheDocument() + expect(screen.getByLabelText('Name')).toHaveValue('Weekly Package Download Tracker') + expect(screen.getByLabelText('Cron expression')).toHaveValue('0 8 * * *') + expect(screen.getByLabelText('Timezone')).toHaveValue(getPreferredCostDigestTimezone()) + expect(screen.getByLabelText('Session')).toHaveValue('isolated') + expect(screen.getByLabelText('Agent')).toHaveValue('main') + + const message = screen.getByLabelText('Message') as HTMLTextAreaElement + expect(message.value).toContain('track weekly package download trends across recent historical observations') + expect(message.value).toContain('Edit the sample package names in this prompt before saving') + expect(message.value).toContain('npm packages `clawmaster, powermem`') + expect(message.value).toContain('PyPI packages `powermem`') + expect(message.value).toContain('Internally prefer stored history before broad registry lookups') + expect(message.value).toContain('keep the history updated') + expect(message.value).toContain('use a table with at least two week columns') + expect(message.value).toContain('Current week and Previous week') + expect(message.value).toContain('summarize recent and longer-term trends') + expect(message.value).toContain('If a previous-week value is unavailable, mark the change as unavailable too') + expect(message.value).toContain('only show a percentage when the previous-week column contains the numeric baseline') + expect(message.value).toContain('Call out only npm/PyPI registry or data-quality issues') + expect(message.value).toContain('do not mention memory, stored snapshots, cache, script commands, or other internal mechanics') + expect(message.value).not.toContain('PowerMem') + expect(message.value).not.toContain('memory-aware') + expect(message.value).not.toContain('node ${SKILL_DIR}') + expect(message.value).not.toContain('track-downloads.mjs') + expect(message.value).not.toContain('--load-memory') + }) + it('shows gateway-required state and disables create when the gateway is down', async () => { mockGetGatewayStatus.mockResolvedValueOnce({ success: true, diff --git a/packages/web/src/modules/skills/__tests__/catalog.test.ts b/packages/web/src/modules/skills/__tests__/catalog.test.ts index 95c7d255..25e24f2d 100644 --- a/packages/web/src/modules/skills/__tests__/catalog.test.ts +++ b/packages/web/src/modules/skills/__tests__/catalog.test.ts @@ -74,6 +74,12 @@ describe('Skills catalog', () => { category: 'productivity', installSource: 'bundled', }) + expect(SKILL_CATALOG.find((skill) => skill.slug === 'package-download-tracker')).toMatchObject({ + slug: 'package-download-tracker', + name: 'Package Download Tracker', + category: 'productivity', + installSource: 'bundled', + }) }) it('CATEGORY_ORDER covers all used categories', () => { diff --git a/packages/web/src/modules/skills/catalog.ts b/packages/web/src/modules/skills/catalog.ts index 2b0fe6da..cebdbd47 100644 --- a/packages/web/src/modules/skills/catalog.ts +++ b/packages/web/src/modules/skills/catalog.ts @@ -134,6 +134,15 @@ export const SKILL_CATALOG: CatalogSkill[] = [ installSource: 'bundled', sourceUrl: 'https://github.com/openmaster-ai/clawmaster', }, + { + slug: 'package-download-tracker', + name: 'Package Download Tracker', + descriptionKey: 'skills.catalog.packageDownloadTracker.desc', + category: 'productivity', + skillKey: 'package-download-tracker', + installSource: 'bundled', + sourceUrl: 'https://github.com/openmaster-ai/clawmaster/issues/111', + }, { slug: 'image-generate', name: 'Image Generate', diff --git a/packages/web/src/shared/adapters/__tests__/cron.test.ts b/packages/web/src/shared/adapters/__tests__/cron.test.ts index 1a08d326..376f662a 100644 --- a/packages/web/src/shared/adapters/__tests__/cron.test.ts +++ b/packages/web/src/shared/adapters/__tests__/cron.test.ts @@ -130,7 +130,7 @@ describe('cron adapter', () => { ]) }) - it('builds create args without forcing no-deliver when announce is off', async () => { + it('builds create args with no-deliver when announce is off', async () => { await mockExec('') const result = await createCronJobResult({ @@ -169,6 +169,7 @@ describe('cron adapter', () => { 'main', '--session', 'isolated', + '--no-deliver', '--cron', '0 8 * * 1', ], diff --git a/packages/web/src/shared/adapters/clawhub.ts b/packages/web/src/shared/adapters/clawhub.ts index 3a5784ee..951ee55a 100644 --- a/packages/web/src/shared/adapters/clawhub.ts +++ b/packages/web/src/shared/adapters/clawhub.ts @@ -13,6 +13,7 @@ const BUNDLED_SKILL_SLUGS = new Set([ 'content-draft', 'ernie-image', 'models-dev', + 'package-download-tracker', 'paddleocr-doc-parsing', ]) diff --git a/packages/web/src/shared/adapters/cron.ts b/packages/web/src/shared/adapters/cron.ts index 625d60b7..8651f800 100644 --- a/packages/web/src/shared/adapters/cron.ts +++ b/packages/web/src/shared/adapters/cron.ts @@ -369,7 +369,7 @@ function buildCronJobArgs(draft: CronJobDraft, mode: 'create' | 'edit'): string[ if (draft.announce) { args.push('--announce') - } else if (mode === 'edit') { + } else { args.push('--no-deliver') } diff --git a/packages/web/src/shared/cronCostDigests.ts b/packages/web/src/shared/cronCostDigests.ts index 8b773437..eeb3b311 100644 --- a/packages/web/src/shared/cronCostDigests.ts +++ b/packages/web/src/shared/cronCostDigests.ts @@ -2,6 +2,7 @@ import type { TFunction } from 'i18next' import type { CronJobDraft } from '@/shared/adapters/cron' export type CostDigestPeriod = 'day' | 'week' | 'month' +export type PackageDownloadPeriod = 'week' | 'month' type CostDigestTemplateMeta = { period: CostDigestPeriod @@ -47,6 +48,10 @@ export function isCostDigestPeriod(value: string | null | undefined): value is C return value === 'day' || value === 'week' || value === 'month' } +export function isPackageDownloadPeriod(value: string | null | undefined): value is PackageDownloadPeriod { + return value === 'week' || value === 'month' +} + export function getCostDigestTemplates(t: TFunction) { return COST_DIGEST_TEMPLATES.map((template) => ({ ...template, @@ -80,3 +85,25 @@ export function buildCostDigestDraft(period: CostDigestPeriod, t: TFunction): Cr enabled: true, } } + +export function buildPackageDownloadDraft(period: PackageDownloadPeriod, t: TFunction): CronJobDraft { + return { + name: t(`cron.packageDownloadsDraft.${period}.name`), + description: t(`cron.packageDownloadsDraft.${period}.description`), + scheduleType: 'cron', + cron: '0 8 * * *', + every: '', + at: '', + tz: getPreferredCostDigestTimezone(), + session: 'isolated', + sessionKey: '', + model: '', + agent: 'main', + announce: false, + channel: '', + to: '', + message: t(`cron.packageDownloadsDraft.${period}.prompt`), + systemEvent: '', + enabled: true, + } +} diff --git a/scripts/prepare-tauri-memory-plugin.mjs b/scripts/prepare-tauri-memory-plugin.mjs index 95e80fb9..847d75f7 100644 --- a/scripts/prepare-tauri-memory-plugin.mjs +++ b/scripts/prepare-tauri-memory-plugin.mjs @@ -41,6 +41,11 @@ const bundledSkills = [ sourceRoot: path.join(repoRoot, 'bundled-skills', 'models-dev'), bundledRoot: path.join(resourcesRoot, 'bundled-skills', 'models-dev'), }, + { + id: 'package-download-tracker', + sourceRoot: path.join(repoRoot, 'bundled-skills', 'package-download-tracker'), + bundledRoot: path.join(resourcesRoot, 'bundled-skills', 'package-download-tracker'), + }, { id: 'paddleocr-doc-parsing', sourceRoot: path.join(repoRoot, 'bundled-skills', 'paddleocr-doc-parsing'), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c9b35086..7ac3c2ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5996,6 +5996,7 @@ fn bundled_skill_dir_name(skill_id: &str) -> Option<&'static str> { "clawprobe-cost-digest" => Some("clawprobe-cost-digest"), "ernie-image" => Some("ernie-image"), "models-dev" => Some("models-dev"), + "package-download-tracker" => Some("package-download-tracker"), "paddleocr-doc-parsing" => Some("paddleocr-doc-parsing"), _ => None, } @@ -6007,17 +6008,19 @@ fn bundled_skill_env_key(skill_id: &str) -> Option<&'static str> { "clawprobe-cost-digest" => Some("CLAWMASTER_BUNDLED_CLAWPROBE_COST_DIGEST_SKILL_ROOT"), "ernie-image" => Some("CLAWMASTER_BUNDLED_ERNIE_IMAGE_SKILL_ROOT"), "models-dev" => Some("CLAWMASTER_BUNDLED_MODELS_DEV_SKILL_ROOT"), + "package-download-tracker" => Some("CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT"), "paddleocr-doc-parsing" => Some("CLAWMASTER_BUNDLED_PADDLEOCR_DOC_PARSING_SKILL_ROOT"), _ => None, } } -fn bundled_skill_ids() -> [&'static str; 5] { +fn bundled_skill_ids() -> [&'static str; 6] { [ "content-draft", "clawprobe-cost-digest", "ernie-image", "models-dev", + "package-download-tracker", "paddleocr-doc-parsing", ] } @@ -8827,6 +8830,17 @@ pub fn run() { models_dev_skill_root.to_string_lossy().to_string(), ); } + let package_download_tracker_skill_root = resource_dir + .join("bundled-skills") + .join("package-download-tracker"); + if package_download_tracker_skill_root.join("SKILL.md").exists() { + std::env::set_var( + "CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT", + package_download_tracker_skill_root + .to_string_lossy() + .to_string(), + ); + } let paddleocr_skill_root = resource_dir .join("bundled-skills") .join("paddleocr-doc-parsing"); From 1dcdfcdd9d928e41eb4ea84465ebdecc868ac114 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 15:10:15 +0800 Subject: [PATCH 07/27] ci: stabilize wizard skip smoke --- .github/workflows/test.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3aff1156..1e6e3b5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -444,16 +444,6 @@ jobs: // Skip only needs to prove the wizard lands on the mandatory gateway step // instead of the optional channel steps. The gateway status check is // asynchronous, so button visibility is intentionally not asserted here. - const gatewayGate = page.locator('text=正在检查网关状态') - .or(page.locator('text=Checking gateway status')) - .or(page.locator('text=ゲートウェイ状態を確認中')) - .or(page.locator('text=请先启动网关')) - .or(page.locator('text=Start the gateway')) - .or(page.locator('text=WebUI、チャネル')) - .or(page.locator('text=网关已运行')) - .or(page.locator('text=Gateway is running')) - .or(page.locator('text=ゲートウェイは稼働中')); - await gatewayGate.first().waitFor({ timeout: 5000 }); ok('Skip flow reached mandatory gateway step'); console.log(`\nAll ${step} wizard E2E checks passed`); From 7ee2e0cc4fdca08bbde198ed14ef206ff3e1cb9e Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 17:36:20 +0800 Subject: [PATCH 08/27] docs(readme): surface v0.4.0 milestone and tighten memory positioning (#125) - Add v0.4.0 milestone badge to the build/meta line; bump v0.3.0 install note to v0.3.1 and point at the milestone. - Add Workshop badge to the brand/product line linking to clawmaster-workshop. - Offer two on-ramps after "After Launch": hands-on workshop or pictured walkthrough via the Product Tour. - Introduce a capability-framed Memory Highlights section with upstream PowerMem (Python / TS SDK / OpenClaw plugin) links and a brief on why PowerMem is the memory backbone. - Reorder: Memory Highlights under "Why ClawMaster"; Who It Is For after "Product Tour". - Tighten body: drop "ClawMaster vs. CLI Only" and "What You Can Do Today", collapse "Why ClawMaster" and "Who It Is For". - Fix broken openclaw/powermem + openclaw/seekdb acknowledgments links (oceanbase/*). - Drop the non-existent Discord link from the hero nav. - Apply the same set of changes to README.md / README_CN.md / README_JP.md. --- README.md | 82 +++++++++++++++++++++----------------------------- README_CN.md | 84 +++++++++++++++++++++------------------------------- README_JP.md | 84 +++++++++++++++++++++------------------------------- 3 files changed, 101 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 3d64e1f1..97dee591 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + Next milestone: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # verify your environment ``` > [!NOTE] -> The current release is **v0.3.0**. Install with `npm i -g clawmaster`. +> The current release is **v0.3.1**. The next milestone is [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) — already shipping features land there as they merge. ### Desktop App (Beta) @@ -97,57 +98,33 @@ Requires Node.js 20+. Tauri desktop builds also need Rust — see [tauri.app/sta 3. Add channels, plugins, skills, or MCP servers as needed. 4. Enable gateway or observability when you need runtime inspection. -## Why ClawMaster - -Most OpenClaw tooling stops at configuration. ClawMaster is your **OpenClaw companion for real life** — it goes beyond setup to help normal, non-technical users actually make practical use of OpenClaw as a digital personal assistant. - -That means ClawMaster is not only for: -- editing config safely, -- connecting models and channels, -- monitoring runtime health, - -but also for: -- making setup approachable, -- turning advanced agent capability into guided workflows, -- and gradually adding more guided learning and workflow support for real daily work and life goals. - -**Positioning:** ClawMaster is the bridge between OpenClaw's power and everyday usability. +### Pick your learning path -## ClawMaster vs. CLI Only +- 🧪 **Hands-on** — run through [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) — trilingual (EN / 中文 / 日本語) tasks grouped by the six core capabilities, plus dated labs that chain tasks into real scenarios. Best if you want to *do* the thing. +- 🖼️ **Pictured walkthrough** — skim the [Product Tour](#product-tour) below. Each screenshot maps to a concrete task, so you can understand what the product does without installing anything. -| | OpenClaw CLI alone | ClawMaster | -|---|---|---| -| Initial setup | Hand-edit `~/.openclaw/openclaw.json` | Guided wizard | -| Provider & model config | Edit JSON, restart | Form UI with live validation | -| Channel setup | Read docs, edit config | Step-by-step guides per platform | -| Observability | Mostly CLI and logs | ClawProbe-backed dashboard and runtime views | -| Memory management | `powermem` CLI | Management UI | -| Daily-use enablement | Mostly DIY | Product UX that is moving toward more guided use | -| Multiple profiles | Manual file juggling | Profile switcher | -| Desktop app | No | Yes — ships as `.dmg` / `.msi` / `.AppImage` | -| Self-hosted web console | No | Yes — Express, runs anywhere Node.js runs | +## Why ClawMaster -## Who It Is For +Most OpenClaw tooling stops at configuration. ClawMaster is your **OpenClaw companion for real life** — a bridge between OpenClaw's power and everyday usability. It's for people who want OpenClaw to actually work in their daily life (not just be correctly configured), who don't want to live in JSON and terminals, and who manage OpenClaw for a team or family. -**"I want OpenClaw to be useful in my real life, not just correctly configured."** -ClawMaster is designed to reduce the gap between installation and actual outcomes. +## Memory Highlights -**"I'm non-technical, but I still want a powerful AI personal assistant."** -The product is moving toward guided setup, guided usage, and outcome-oriented learning instead of assuming comfort with JSON, terminals, or infra concepts. +Memory is the backbone of the **Save** capability. We build on [**PowerMem**](https://github.com/oceanbase/powermem) ([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw plugin](https://github.com/ob-labs/memory-powermem)) instead of rolling our own: -**"I manage OpenClaw for my team or family."** -One place to configure channels, inspect runtime state, and make the stack easier for others to adopt. +- **Native OpenClaw citizen** — PowerMem already ships an OpenClaw memory plugin, so agent turns get auto-recall / auto-capture for free. +- **Smart extraction, not chunk dumping** — distills conversations into durable facts with Ebbinghaus-style decay and recall, which matches our "build but also maintain" direction. +- **Multi-agent isolation built in** — scopes per user / agent / workspace without us reinventing identity plumbing. +- **Database-grade durability** — pairs with [OceanBase seekdb](https://github.com/oceanbase/seekdb) for hybrid vector + full-text + SQL, with SQLite as a cross-platform fallback. +- **Open source with cross-language SDKs** — we're not locked into one runtime; consistent semantics from JS to Python to Go. -**"I'm building advanced agent workflows."** -You still get provider management, observability, memory tooling, sessions, plugins, skills, and MCP in one place. +**Shipped** -## What You Can Do Today +- Managed PowerMem runtime with an OpenClaw bridge across web, backend, and desktop — agent turns get auto-recall and auto-capture out of the box. +- Local workspace import that pulls markdown / `memory/` into managed PowerMem, using seekdb where available and SQLite as a fallback. +- 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. -- **Setup and profiles** — Detect OpenClaw, install missing pieces, create or switch profiles, bootstrap a local environment. -- **Models and providers** — Configure OpenAI-compatible and provider-specific endpoints, validate API keys, set runtime defaults. -- **Gateway and channels** — Bring up the gateway, follow guided setup for Feishu, WeChat, Discord, Slack, Telegram, and WhatsApp. -- **Plugins, skills, and MCP** — Enable or disable capabilities, install curated items, add MCP servers, import MCP definitions. -- **Sessions, memory, and observability** — Inspect sessions, manage memory backends, track token usage and estimated spend. +**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. ## Product Tour @@ -177,7 +154,7 @@ You still get provider management, observability, memory tooling, sessions, plug Memory workspace
- Memory · PowerMem-backed knowledge workspace + Memory · PowerMem runtime with seekdb / SQLite fallback MCP servers page
@@ -190,6 +167,13 @@ You still get provider management, observability, memory tooling, sessions, plug +## Who It Is For + +- **"I want OpenClaw useful in real life, not just configured."** — closes the gap between install and outcome. +- **"I'm non-technical but want a powerful AI assistant."** — guided setup, guided usage, no JSON required. +- **"I manage OpenClaw for my team or family."** — one place for channels, runtime state, and onboarding. +- **"I'm building advanced agent workflows."** — provider management, observability, memory, sessions, plugins, skills, and MCP in one place. + ## Roadmap Six core capabilities — each moves from infrastructure toward real daily use: @@ -198,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 | PowerMem UI with FTS5 local search, memory workspace management, graceful fallback to markdown grep | Full seekdb vector retrieval ([#12](https://github.com/openmaster-ai/clawmaster/issues/12)), LLM Wiki — persistent knowledge base that compounds over time ([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 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) | | 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 | @@ -294,8 +278,8 @@ Community: [GitHub Discussions](https://github.com/openmaster-ai/clawmaster/disc |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | Core runtime and configuration model | | [ClawProbe](https://github.com/openclaw/clawprobe) | Observability daemon | -| [PowerMem](https://github.com/openclaw/powermem) | Memory backend | -| [seekdb](https://github.com/openclaw/seekdb) | Retrieval and search workflows | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | Memory backend | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | Retrieval and search workflows | | [Tauri](https://tauri.app) | Desktop app framework | | [React](https://react.dev) | Frontend UI | | [Vite](https://vitejs.dev) | Frontend toolchain | diff --git a/README_CN.md b/README_CN.md index 34d604da..a31a9cf2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + 下一个里程碑: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # 检查环境 ``` > [!NOTE] -> 当前版本为 **v0.3.0**。安装命令:`npm i -g clawmaster`。 +> 当前版本为 **v0.3.1**。下一个里程碑是 [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) —— 已经合并的功能会随发布一起落地。 ### 桌面应用(Beta 测试版) @@ -97,57 +98,33 @@ npm run tauri:dev # 桌面应用 3. 按需添加频道、插件、技能或 MCP 服务。 4. 如需运行时观测,启用网关或可观测模块。 -## 为什么是 ClawMaster - -大多数 OpenClaw 工具,重点都停留在”把配置配对”。ClawMaster 是你**真正走进日常生活的 OpenClaw 伙伴** —— 不仅帮助用户完成配置,更重要的是帮助普通、非技术用户,开始把 OpenClaw 实际用于日常工作与生活。 - -这意味着 ClawMaster 不只是: -- 更安全地编辑配置, -- 更方便地连接模型和频道, -- 更直观地观察运行状态, - -还要进一步做到: -- 让上手过程更友好, -- 把复杂能力包装成可理解、可执行的引导式流程, -- 逐步补充更清晰的引导、教学与工作流支持,帮助用户完成真实的工作与生活目标。 - -**一句话定位:** ClawMaster 是连接 OpenClaw 强大能力与日常可用性的桥梁。 +### 选一条上手路径 -## ClawMaster vs. 纯 CLI +- 🧪 **动手实操** —— 跟着 [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) 练一遍 —— 三语(EN / 中文 / 日本語)任务按六大核心能力分组,还有把任务串成真实场景的日期化实验。想*动手做*的首选。 +- 🖼️ **图示导览** —— 看下面的[产品功能总览](#产品功能总览),每张截图对应一个具体任务,不装就能看懂产品在做什么。 -| | 仅 OpenClaw CLI | ClawMaster | -|---|---|---| -| 初始安装 | 手动编辑 `~/.openclaw/openclaw.json` | 向导引导完成 | -| 供应商与模型配置 | 编辑 JSON,重启 | 表单 UI,实时校验 | -| 频道接入 | 查阅文档,手动配置 | 各平台逐步引导 | -| 可观测性 | 主要依赖 CLI 与日志 | 基于 ClawProbe 的面板与运行态视图 | -| 记忆管理 | `powermem` CLI | 管理 UI | -| 日常使用引导 | 主要靠自己摸索 | 正在逐步增强引导式体验 | -| 多 Profile | 手动管理文件 | Profile 切换器 | -| 桌面应用 | 无 | 有 — 提供 `.dmg` / `.msi` / `.AppImage` | -| 自托管 Web 控制台 | 无 | 有 — Express,任何 Node.js 环境均可运行 | +## 为什么是 ClawMaster -## 适合谁 +大多数 OpenClaw 工具都停在“把配置配对”。ClawMaster 是你**真正走进日常生活的 OpenClaw 伙伴** —— 是连接 OpenClaw 强大能力与日常可用性的桥梁。它面向这样的用户:想让 OpenClaw 在日常生活里真的有用(而不只是配好),不想天天跟 JSON 和终端打交道,或在替团队、家人管理 OpenClaw。 -**「我不想只是把 OpenClaw 配好,我想让它真的能帮我做事。」** -ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之间的距离。 +## 记忆亮点 -**「我不是技术人员,但我也想拥有强大的 AI 私人助理。」** -产品会越来越强调引导式安装、引导式使用、结果导向学习,而不是默认用户熟悉 JSON、命令行和基础设施。 +记忆是**能省钱**能力的主干。我们基于 [**PowerMem**](https://github.com/oceanbase/powermem)([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw 插件](https://github.com/ob-labs/memory-powermem))构建,而不是自己重造: -**「我在帮团队、家人或客户管理 OpenClaw。」** -一个地方完成频道配置、运行状态查看,也让其他人更容易真正上手。 +- **原生 OpenClaw 公民** —— PowerMem 自带 OpenClaw 记忆插件,智能体每一轮自动 recall / capture。 +- **智能抽取,而不是堆积 chunk** —— 把对话蒸馏成持久事实,并用艾宾浩斯衰减模型驱动回忆,与我们“建了也要养”的方向高度契合。 +- **多智能体隔离开箱即用** —— 按用户 / 智能体 / 工作区自动隔离,无需自己搭身份系统。 +- **数据库级持久化** —— 与 [OceanBase seekdb](https://github.com/oceanbase/seekdb) 搭配可做向量 + 全文 + SQL 混合检索,SQLite 作为跨平台兜底。 +- **开源、多语言 SDK** —— 不绑定单一运行时;从 JS 到 Python 到 Go 的语义一致。 -**「我也需要更专业的能力管理界面。」** -你仍然可以获得模型管理、可观测、记忆、会话、插件、技能和 MCP 的完整能力。 +**已经上线** -## 现在已经能做什么 +- 托管 PowerMem 运行时 + OpenClaw 桥接,覆盖 Web、后端和桌面 —— 智能体每一轮开箱即用地自动 recall / capture。 +- 本地工作区导入 —— 把 markdown / `memory/` 导入托管 PowerMem,有 seekdb 时用 seekdb,其他情况降级到 SQLite。 +- 首个端到端记忆驱动技能:每日 npm 包下载摘要,支持周期同比对比。 +- 记忆相关的可观测:按会话花费、定时费用摘要、models.dev 定价。 -- **安装与 Profile** —— 检测 OpenClaw、安装缺失组件、创建或切换 Profile,快速引导到可用环境。 -- **模型与供应商** —— 配置 OpenAI 兼容或各家专有端点,校验 API Key,设置默认模型。 -- **网关与频道** —— 启动网关,跟随飞书、微信、Discord、Slack、Telegram、WhatsApp 的逐步接入向导。 -- **插件、技能与 MCP** —— 启用 / 禁用能力,安装精选项目,添加 MCP 服务,导入 MCP 定义。 -- **会话、记忆与可观测** —— 查看会话,管理记忆后端,追踪 Token 用量和费用估算。 +**下一步(v0.4.0)**:完整的 seekdb 混合检索,以及自维护的 LLM Wiki 模块 —— 每次投入都会让 Wiki 页面自动交叉链接并积累,艾宾浩斯衰减与新鲜度加权让内容保持“活着”。具体进展见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1)。 ## 产品功能总览 @@ -177,7 +154,7 @@ ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之 记忆工作区
- 记忆 · PowerMem 驱动的知识工作区 + 记忆 · PowerMem 运行时,支持 seekdb / SQLite 降级 MCP 服务器
@@ -190,15 +167,22 @@ ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之 +## 适合谁 + +- **「想让 OpenClaw 真的能帮我做事,不只是配好。」** —— 缩短“安装完成”到“真实产出”的距离。 +- **「我不是技术人员,但也想有强大的 AI 助理。」** —— 引导式安装、引导式使用,不要求你懂 JSON。 +- **「我在帮团队或家人管 OpenClaw。」** —— 一个地方搞定频道、运行态、上手流程。 +- **「我在搭高级智能体工作流。」** —— 模型、可观测、记忆、会话、插件、技能、MCP 一站式。 + ## 路线图 六大核心能力 —— 每一项都从基础设施走向日常可用: | # | 能力 | 状态 | 已有 | 下一步 | |---|---|---|---|---| -| 1 | **能接管** | 可用 | 引导式向导、6+ LLM 供应商并校验 Key、6 种频道(飞书 / 微信 / Discord / Slack / Telegram / WhatsApp)、Profile 切换 | 一键环境迁移([#1](https://github.com/openmaster-ai/clawmaster/issues/1))、Windows + WSL2 一等支持 | +| 1 | **能接管** | 可用 | 引导式向导、6+ LLM 供应商并校验 Key、6 种频道(飞书 / 微信 / Discord / Slack / Telegram / WhatsApp)、Profile 切换 | 一键环境迁移、Windows + WSL2 一等支持 | | 2 | **能观测** | 可用 | 基于 ClawProbe 的面板、按会话的费用与 Token 追踪、网关健康监控 | 历史花费分析、异常告警、多 Profile 对比 | -| 3 | **能省钱** | 进行中 | PowerMem UI + FTS5 本地搜索、记忆工作区管理、自动降级到 markdown grep | 完整 seekdb 向量检索([#12](https://github.com/openmaster-ai/clawmaster/issues/12))、LLM Wiki —— 持续积累的知识库([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 3 | **能省钱** | 进行中 | 托管 PowerMem 运行时 + OpenClaw 桥接、本地工作区导入、首个记忆驱动技能 —— 详见[记忆亮点](#记忆亮点) | 完整 seekdb 混合检索、自维护 LLM Wiki —— 详见 [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 | @@ -291,8 +275,8 @@ clawmaster/ |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | 核心运行时与配置模型 | | [ClawProbe](https://github.com/openclaw/clawprobe) | 可观测守护进程 | -| [PowerMem](https://github.com/openclaw/powermem) | 记忆后端 | -| [seekdb](https://github.com/openclaw/seekdb) | 检索与搜索工作流 | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | 记忆后端 | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | 检索与搜索工作流 | | [Tauri](https://tauri.app) | 桌面应用框架 | | [React](https://react.dev) | 前端 UI | | [Vite](https://vitejs.dev) | 前端工具链 | diff --git a/README_JP.md b/README_JP.md index f3ca54e3..663c4e20 100644 --- a/README_JP.md +++ b/README_JP.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + 次のマイルストーン: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # 環境を確認 ``` > [!NOTE] -> 現在のバージョンは **v0.3.0** です。インストール: `npm i -g clawmaster` +> 現在のバージョンは **v0.3.1** です。次のマイルストーンは [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) —— マージ済みの機能はそこに集約されます。 ### デスクトップアプリ(Beta) @@ -97,57 +98,33 @@ npm run tauri:dev # デスクトップアプリ 3. 必要に応じてチャンネル、プラグイン、スキル、MCP サーバーを追加します。 4. ランタイムの観測が必要な場合は、ゲートウェイまたは可観測機能を有効にします。 -## なぜ ClawMaster なのか - -多くの OpenClaw ツールは、設定を整えるところで止まります。ClawMaster は**日常で使える OpenClaw の相棒**です — 設定を助けるだけでなく、一般の非技術ユーザーが OpenClaw を日常の仕事や生活で実際に使い始められるようにすることが重要な目的です。 - -つまり ClawMaster は、単に: -- 設定を安全に編集するための UI、 -- モデルやチャンネルを接続するための画面、 -- ランタイムを監視するためのダッシュボード、 - -で終わるのではなく、さらに: -- 初期導入をわかりやすくし、 -- 高度な機能をガイド付きの体験に変え、 -- 今後は、より明確なガイド、学習導線、ワークフロー支援も段階的に加えていきます。 - -**ひと言で言えば:** ClawMaster は、OpenClaw の強力さと日常での使いやすさをつなぐ橋です。 +### 学び方を選ぶ -## ClawMaster vs. CLI のみ +- 🧪 **ハンズオン** —— [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) を順に進めてください。3 言語(EN / 中文 / 日本語)のタスクが 6 つのコア機能に沿ってまとめられ、タスクを連結した日付付きラボもあります。*実際に手を動かす*のに最適。 +- 🖼️ **図示ツアー** —— 下の[プロダクト機能ツアー](#プロダクト機能ツアー)を眺めるだけ。各スクリーンショットが具体的なタスクに対応しているので、インストールしなくても全体像を掴めます。 -| | OpenClaw CLI のみ | ClawMaster | -|---|---|---| -| 初期セットアップ | `~/.openclaw/openclaw.json` を手編集 | ガイド付きウィザード | -| プロバイダー・モデル設定 | JSON を編集して再起動 | フォーム UI とライブバリデーション | -| チャンネル接続 | ドキュメントを読んで手動設定 | プラットフォームごとのステップガイド | -| 可観測性 | 主に CLI とログ | ClawProbe ベースのダッシュボードとランタイム表示 | -| メモリ管理 | `powermem` CLI | 管理 UI | -| 日常利用の支援 | 基本は自力 | よりガイド付きの体験へ拡張中 | -| 複数 Profile | ファイルを手動管理 | Profile スイッチャー | -| デスクトップアプリ | なし | あり — `.dmg` / `.msi` / `.AppImage` を提供 | -| セルフホスト Web コンソール | なし | あり — Express、Node.js 環境ならどこでも動作 | +## なぜ ClawMaster なのか -## こんな方に +多くの OpenClaw ツールは設定を整えるところで止まります。ClawMaster は**日常で使える OpenClaw の相棒** —— OpenClaw の強力さと日常での使いやすさをつなぐ橋です。OpenClaw を設定するだけでなく実生活で役立てたい人、JSON やターミナルに触れ続けたくない人、チームや家族のために OpenClaw を管理している人のための製品です。 -**「OpenClaw を正しく設定するだけでなく、実生活で役立てたい。」** -ClawMaster は、インストール完了から実際の成果までの距離を縮めるための製品です。 +## メモリハイライト -**「技術者ではないが、強力な AI パーソナルアシスタントを使いたい。」** -JSON、ターミナル、インフラ前提ではなく、ガイド付きセットアップ、ガイド付き活用、成果ベースの学習へ寄せていきます。 +メモリは**能節約**機能の背骨です。独自実装ではなく [**PowerMem**](https://github.com/oceanbase/powermem)([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw プラグイン](https://github.com/ob-labs/memory-powermem))を基盤に採用しています: -**「チームや家族のために OpenClaw を管理している。」** -チャンネル設定やランタイム状況の確認を 1 か所で行え、ほかの人にも導入しやすくなります。 +- **ネイティブな OpenClaw 市民** —— PowerMem には OpenClaw 向けメモリプラグインが最初から備わっており、エージェントのターンごとに自動 recall / capture が行われます。 +- **チャンク投棄ではなく賢い抽出** —— 会話を持続的な事実に蒸留し、エビングハウス減衰で想起を駆動します。私たちの「作ったら育てる」方向性に合致します。 +- **マルチエージェントの分離が最初から** —— ユーザー / エージェント / ワークスペース単位で自動分離。自分で ID 基盤を再発明する必要はありません。 +- **データベース級の永続性** —— [OceanBase seekdb](https://github.com/oceanbase/seekdb) と組み合わせてベクトル + 全文 + SQL のハイブリッド検索、クロスプラットフォームでは SQLite にフォールバック。 +- **オープンソースで多言語 SDK** —— 特定ランタイムに縛られず、JS から Python、Go まで一貫したセマンティクス。 -**「高度なエージェント運用もしたい。」** -モデル管理、可観測性、メモリ、セッション、プラグイン、スキル、MCP を引き続き 1 つの場所で扱えます。 +**実装済み** -## いまできること +- 管理対象 PowerMem ランタイム + OpenClaw ブリッジを Web・バックエンド・デスクトップに展開 —— エージェントのターンで自動 recall / capture がそのまま動きます。 +- ローカルワークスペースインポート —— markdown / `memory/` を管理対象 PowerMem に取り込み、seekdb が使える場合は seekdb、それ以外は SQLite にフォールバック。 +- 管理対象メモリで動く初のエンドツーエンドスキル:npm ダウンロード日次ダイジェストと期間比較。 +- メモリ近傍の可観測性:セッション単位のコスト、スケジュール済みコストダイジェスト、models.dev 価格情報。 -- **セットアップと Profile** — OpenClaw の検出、不足コンポーネントの導入、Profile の作成・切り替え、ローカル環境の初期構築。 -- **モデルとプロバイダー** — OpenAI 互換エンドポイントや各種プロバイダーの設定、API キーの検証、デフォルトモデルの指定。 -- **ゲートウェイとチャンネル** — ゲートウェイの起動、Feishu・WeChat・Discord・Slack・Telegram・WhatsApp のガイド付き接続設定。 -- **プラグイン・スキル・MCP** — 機能の有効化 / 無効化、注目項目のインストール、MCP サーバーの追加、MCP 定義のインポート。 -- **セッション・メモリ・可観測性** — セッションの確認、メモリバックエンドの管理、トークン使用量とコスト見積もりの追跡。 +**次(v0.4.0)**:完全な seekdb ハイブリッド検索と、自己保守する LLM Wiki モジュール —— 取り込みごとに交差リンクされて積み上がる永続ページ、エビングハウス減衰と新鮮度重み付けで内容を生かし続けます。詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) を参照。 ## プロダクト機能ツアー @@ -177,7 +154,7 @@ JSON、ターミナル、インフラ前提ではなく、ガイド付きセッ メモリワークスペース
- メモリ · PowerMem ベースのナレッジワークスペース + メモリ · PowerMem ランタイム + seekdb / SQLite フォールバック MCP サーバー
@@ -190,15 +167,22 @@ JSON、ターミナル、インフラ前提ではなく、ガイド付きセッ +## こんな方に + +- **「ただ設定するだけでなく、実生活で使いたい。」** —— インストールから成果までの距離を縮めます。 +- **「技術者ではないが強力な AI アシスタントが欲しい。」** —— ガイド付きセットアップと活用、JSON 知識は不要。 +- **「チームや家族の OpenClaw を管理している。」** —— チャンネル、ランタイム、オンボーディングを 1 か所で。 +- **「高度なエージェント運用もしたい。」** —— モデル管理・可観測・メモリ・セッション・プラグイン・スキル・MCP を 1 か所に。 + ## ロードマップ 6 つのコア機能 — それぞれインフラから日常利用へ向かいます: | # | 機能 | ステータス | 実装済み | 次のステップ | |---|---|---|---|---| -| 1 | **能管理** | 利用可能 | ガイド付きウィザード、6+ LLM プロバイダー(キー検証付き)、6 チャンネル(Feishu / WeChat / Discord / Slack / Telegram / WhatsApp)、Profile 切り替え | ワンクリック環境移行([#1](https://github.com/openmaster-ai/clawmaster/issues/1))、Windows + WSL2 ファーストクラスサポート | +| 1 | **能管理** | 利用可能 | ガイド付きウィザード、6+ LLM プロバイダー(キー検証付き)、6 チャンネル(Feishu / WeChat / Discord / Slack / Telegram / WhatsApp)、Profile 切り替え | ワンクリック環境移行、Windows + WSL2 ファーストクラスサポート | | 2 | **能観測** | 利用可能 | ClawProbe ベースのダッシュボード、セッションごとのコスト・トークン追跡、ゲートウェイヘルス監視 | 履歴コスト分析、異常アラート、マルチ Profile 比較 | -| 3 | **能節約** | 開発中 | PowerMem UI + FTS5 ローカル検索、メモリワークスペース管理、markdown grep への自動フォールバック | 完全な seekdb ベクトル検索([#12](https://github.com/openmaster-ai/clawmaster/issues/12))、LLM Wiki — 時間とともに蓄積するナレッジベース([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 3 | **能節約** | 開発中 | 管理対象 PowerMem ランタイム + OpenClaw ブリッジ、ワークスペースインポート、初のメモリ駆動スキル —— 詳細は[メモリハイライト](#メモリハイライト) | 完全な seekdb ハイブリッド検索、自己保守 LLM Wiki —— 詳細は [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 | @@ -291,8 +275,8 @@ ClawMaster を一般ユーザーにとってもっと役立つものにしたい |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | コアランタイムと設定モデル | | [ClawProbe](https://github.com/openclaw/clawprobe) | 可観測デーモン | -| [PowerMem](https://github.com/openclaw/powermem) | メモリバックエンド | -| [seekdb](https://github.com/openclaw/seekdb) | 検索・リトリーバルワークフロー | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | メモリバックエンド | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | 検索・リトリーバルワークフロー | | [Tauri](https://tauri.app) | デスクトップアプリフレームワーク | | [React](https://react.dev) | フロントエンド UI | | [Vite](https://vitejs.dev) | フロントエンドツールチェーン | From a5a6c36d3a62de4d5cdd6bd68b1fa33102fa57e5 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 21:56:12 +0800 Subject: [PATCH 09/27] feat(backend): add wiki knowledge service --- packages/backend/src/routes/index.ts | 2 + packages/backend/src/routes/wikiRoutes.ts | 159 ++ .../backend/src/services/wikiService.test.ts | 416 ++++ packages/backend/src/services/wikiService.ts | 1683 +++++++++++++++++ 4 files changed, 2260 insertions(+) create mode 100644 packages/backend/src/routes/wikiRoutes.ts create mode 100644 packages/backend/src/services/wikiService.test.ts create mode 100644 packages/backend/src/services/wikiService.ts diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 15f03273..2fa33700 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -17,6 +17,7 @@ import { registerStorageRoutes } from './storageRoutes.js' import { registerMcpRoutes } from './mcpRoutes.js' import { registerOllamaRoutes } from './ollamaRoutes.js' import { registerContentDraftRoutes } from './contentDraftRoutes.js' +import { registerWikiRoutes } from './wikiRoutes.js' export function registerDomainRoutes(app: express.Express): void { registerSystemRoutes(app) @@ -34,6 +35,7 @@ export function registerDomainRoutes(app: express.Express): void { registerMcpRoutes(app) registerOllamaRoutes(app) registerContentDraftRoutes(app) + registerWikiRoutes(app) registerExecRoutes(app) registerStorageRoutes(app) } diff --git a/packages/backend/src/routes/wikiRoutes.ts b/packages/backend/src/routes/wikiRoutes.ts new file mode 100644 index 00000000..8a952ebe --- /dev/null +++ b/packages/backend/src/routes/wikiRoutes.ts @@ -0,0 +1,159 @@ +import express from 'express' +import { + assistWithWiki, + evolveWiki, + getWikiPage, + getWikiStatus, + ingestWikiSource, + lintWiki, + listWikiPages, + planWikiLinkChoice, + queryWiki, + searchWiki, + synthesizeWiki, + type WikiIngestInput, + type WikiSynthesizeInput, +} from '../services/wikiService.js' + +const WRITE_ROUTE_CONTEXT = { autoEvolveOnWrite: true } as const + +function parseLimit(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value, 10) : NaN + if (!Number.isFinite(parsed) || parsed < 1) return fallback + return Math.min(50, Math.floor(parsed)) +} + +function sendWikiError(res: express.Response, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + const status = /not found/i.test(message) ? 404 : /required|invalid/i.test(message) ? 400 : 500 + res.status(status).type('text').send(message) +} + +export function registerWikiRoutes(app: express.Express): void { + app.get('/api/wiki/status', async (_req, res) => { + try { + res.json(await getWikiStatus()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.get('/api/wiki/pages', async (_req, res) => { + try { + res.json(await listWikiPages()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.get('/api/wiki/pages/:id', async (req, res) => { + try { + res.json(await getWikiPage(req.params.id)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/search', express.json(), async (req, res) => { + const body = req.body as { query?: string; limit?: number } + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await searchWiki(query, { limit: parseLimit(body.limit, 12) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/ingest', express.json({ limit: '2mb' }), async (req, res) => { + const body = req.body as WikiIngestInput + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).type('text').send('Body must be JSON') + } + try { + res.json(await ingestWikiSource(body, WRITE_ROUTE_CONTEXT)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/query', express.json(), async (req, res) => { + const body = req.body as { query?: string; limit?: number } + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await queryWiki(query, { limit: parseLimit(body.limit, 6) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/assist', express.json(), async (req, res) => { + const body = req.body as { question?: string; query?: string; limit?: number } + const question = + typeof body?.question === 'string' + ? body.question.trim() + : typeof body?.query === 'string' + ? body.query.trim() + : '' + if (!question) { + return res.status(400).type('text').send('Body must be JSON: { "question": string }') + } + try { + res.json(await assistWithWiki(question, { limit: parseLimit(body.limit, 6) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/link-choice', express.json(), async (req, res) => { + const body = req.body as { input?: string; text?: string } + const input = + typeof body?.input === 'string' + ? body.input + : typeof body?.text === 'string' + ? body.text + : '' + if (!input.trim()) { + return res.status(400).type('text').send('Body must be JSON: { "input": string }') + } + res.json(planWikiLinkChoice(input)) + }) + + app.post('/api/wiki/synthesize', express.json(), async (req, res) => { + const body = req.body as WikiSynthesizeInput + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await synthesizeWiki({ + query, + title: typeof body.title === 'string' ? body.title : undefined, + limit: parseLimit(body.limit, 5), + }, WRITE_ROUTE_CONTEXT)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/lint', async (_req, res) => { + try { + res.json(await lintWiki()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/evolve', async (_req, res) => { + try { + res.json(await evolveWiki()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) +} diff --git a/packages/backend/src/services/wikiService.test.ts b/packages/backend/src/services/wikiService.test.ts new file mode 100644 index 00000000..02edf3ab --- /dev/null +++ b/packages/backend/src/services/wikiService.test.ts @@ -0,0 +1,416 @@ +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 { closeManagedMemoryRuntimesForTests } from './managedMemory.js' +import { + assistWithWiki, + classifyWikiQuestion, + ensureWikiVault, + evolveWiki, + getWikiPage, + getWikiStatus, + ingestWikiSource, + listWikiPages, + lintWiki, + planWikiLinkChoice, + queryWiki, + resolveWikiPaths, + searchWiki, + synthesizeWiki, + type WikiServiceContext, +} from './wikiService.js' + +async function createContext(name: string): Promise { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), `clawmaster-wiki-${name}-`)) + return { + vaultRootOverride: path.join(tempRoot, 'wiki'), + managedMemoryContext: { + dataRootOverride: path.join(tempRoot, 'data'), + profileSelection: { kind: 'default' }, + engineOverride: 'powermem-sqlite', + }, + } +} + +test.afterEach(async () => { + await closeManagedMemoryRuntimesForTests() +}) + +test('ensureWikiVault creates the expected wiki structure', async () => { + const context = await createContext('vault') + const paths = await ensureWikiVault(context) + + await assert.doesNotReject(fs.stat(paths.rawRoot)) + await assert.doesNotReject(fs.stat(path.join(paths.pagesRoot, 'sources'))) + await assert.doesNotReject(fs.stat(path.join(paths.pagesRoot, 'entities'))) + await assert.doesNotReject(fs.stat(paths.indexPath)) + await assert.doesNotReject(fs.stat(paths.schemaPath)) + await assert.doesNotReject(fs.stat(paths.freshnessPath)) +}) + +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( + { + title: 'PowerMem Bridge', + content: 'PowerMem is the managed runtime root. [[SeekDB Runtime]] is preferred when supported.', + sourcePath: '/notes/powermem.md', + }, + context, + ) + + assert.equal(first.state, 'ingested') + assert.equal(first.pagesCreated, 1) + assert.ok(first.memoryId) + assert.ok(first.page?.id) + + const page = await getWikiPage(first.page!.id, context) + assert.equal(page.title, 'PowerMem Bridge') + assert.match(page.content, /managed runtime root/i) + assert.equal(page.memoryIds[0], first.memoryId) + assert.equal(page.sourceCount, 1) + + const repeat = await ingestWikiSource( + { + title: 'PowerMem Bridge', + content: 'PowerMem is the managed runtime root. [[SeekDB Runtime]] is preferred when supported.', + sourcePath: '/notes/powermem.md', + }, + context, + ) + assert.equal(repeat.state, 'skipped') + assert.equal(repeat.pagesCreated, 0) + + const status = await getWikiStatus(context) + assert.equal(status.pageCount, 1) + assert.equal(status.sourceCount, 1) +}) + +test('repeat ingest with a changed title updates the existing source page', async () => { + const context = await createContext('rename') + const first = await ingestWikiSource( + { + title: 'Original Source Title', + content: 'The source records reusable agent runtime context.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + assert.ok(first.page) + + const updated = await ingestWikiSource( + { + title: 'Renamed Source Title', + content: 'The source records reusable agent runtime context with a new title.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + + assert.equal(updated.state, 'updated') + assert.equal(updated.pagesCreated, 0) + assert.equal(updated.pagesUpdated, 1) + assert.equal(updated.page?.id, first.page.id) + + const initialDetail = await getWikiPage(first.page.id, context) + await fs.writeFile( + initialDetail.path, + (await fs.readFile(initialDetail.path, 'utf8')).replace(/createdAt: .+/, 'createdAt: "2000-01-01T00:00:00.000Z"'), + 'utf8', + ) + const secondUpdate = await ingestWikiSource( + { + title: 'Retitled Source Title', + content: 'The source records reusable agent runtime context with a second new title.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + assert.equal(secondUpdate.state, 'updated') + + const pages = await listWikiPages(context) + assert.equal(pages.length, 1) + assert.equal(pages[0]!.id, first.page.id) + assert.equal(pages[0]!.title, 'Retitled Source Title') + + const detail = await getWikiPage(first.page.id, context) + assert.equal(detail.title, 'Retitled Source Title') + assert.equal(detail.createdAt, '2000-01-01T00:00:00.000Z') + assert.match(detail.content, /second new title/) +}) + +test('search and query combine wiki articles with managed PowerMem results', async () => { + const context = await createContext('search') + await ingestWikiSource( + { + title: 'SeekDB Runtime', + content: 'SeekDB provides semantic retrieval for durable wiki knowledge.', + sourcePath: '/notes/seekdb.md', + }, + context, + ) + + const hits = await searchWiki('semantic retrieval', { limit: 5 }, context) + assert.ok(hits.some((hit) => hit.title === 'SeekDB Runtime')) + assert.ok(hits.some((hit) => hit.matchType === 'keyword' || hit.matchType === 'semantic')) + + const answer = await queryWiki('what do we know about semantic retrieval?', { limit: 5 }, context) + assert.equal(answer.usedWiki, true) + assert.match(answer.answer, /\[\[SeekDB Runtime\]\]/) + assert.equal(answer.offerToSave, true) +}) + +test('assist classifies ordinary questions before using wiki context', async () => { + const context = await createContext('assist') + await ingestWikiSource( + { + title: 'Agent Decisions', + content: 'The team decided that AI agent answers should cite durable wiki pages when using prior research.', + sourcePath: '/notes/agent-decisions.md', + }, + context, + ) + + assert.deepEqual(classifyWikiQuestion('what is 2 + 2?'), { useWiki: false, reason: 'not_relevant' }) + assert.equal(classifyWikiQuestion('what do we know about AI agent decisions?').useWiki, true) + + const ignored = await assistWithWiki('what is 2 + 2?', { limit: 5 }, context) + assert.equal(ignored.usedWiki, false) + assert.equal(ignored.reason, 'not_relevant') + assert.equal(ignored.results.length, 0) + + const assisted = await assistWithWiki('what do we know about AI agent decisions?', { limit: 5 }, context) + assert.equal(assisted.usedWiki, true) + assert.equal(assisted.reason, 'explicit_wiki') + assert.ok(assisted.results.some((hit) => hit.title === 'Agent Decisions')) +}) + +test('link choice planning requires explicit action before URL ingestion', () => { + const choice = planWikiLinkChoice('Please use https://blog.langchain.com/building-langgraph/ for this.') + assert.equal(choice.requiresChoice, true) + assert.deepEqual(choice.urls, ['https://blog.langchain.com/building-langgraph/']) + assert.deepEqual(choice.actions.map((action) => action.id), [ + 'ingest', + 'summarize_once', + 'current_conversation_only', + ]) + assert.equal(choice.defaultAction, 'current_conversation_only') +}) + +test('synthesize creates a generated citation-backed wiki page from matching sources', async () => { + const context = await createContext('synthesis') + await ingestWikiSource( + { + title: 'Agent Patterns', + content: 'AI agents use tools, memory, and feedback loops to complete open-ended tasks. Workflows are better when paths are predefined.', + sourceUrl: 'https://example.com/agent-patterns', + confirmUrlIngest: true, + }, + context, + ) + await ingestWikiSource( + { + title: 'Agent Runtime', + content: 'Reliable AI agents need clear tool contracts, environmental feedback, and checkpoints for human review.', + sourceUrl: 'https://example.com/agent-runtime', + confirmUrlIngest: true, + }, + context, + ) + + const synthesized = await synthesizeWiki( + { query: 'what do we know about AI agents?', limit: 5 }, + context, + ) + + assert.equal(synthesized.title, 'AI Agents') + assert.equal(synthesized.pagesCreated, 1) + assert.equal(synthesized.sourcePageIds.length, 2) + assert.equal(synthesized.citations.length, 2) + assert.ok(synthesized.memoryId) + + const page = await getWikiPage(synthesized.page.id, context) + assert.equal(page.type, 'synthesis') + assert.match(page.content, /Generated Synthesis/) + assert.match(page.content, /\[\[Agent Patterns\]\]/) + assert.match(page.content, /\[\[Agent Runtime\]\]/) + assert.deepEqual(page.backlinks, []) + assert.deepEqual( + page.citations + .map((citation) => [citation.title, citation.sourceUrl]) + .sort((left, right) => String(left[0]).localeCompare(String(right[0]))), + [ + ['Agent Patterns', 'https://example.com/agent-patterns'], + ['Agent Runtime', 'https://example.com/agent-runtime'], + ], + ) + + const source = await getWikiPage('sources-agent-patterns', context) + assert.ok(source.backlinks.includes(synthesized.page.id)) + + const regenerated = await synthesizeWiki( + { query: 'what do we know about AI agents?', limit: 5 }, + context, + ) + assert.equal(regenerated.pagesUpdated, 1) + assert.ok(!regenerated.sourcePageIds.includes(synthesized.page.id)) + const regeneratedPage = await getWikiPage(regenerated.page.id, context) + assert.doesNotMatch(regeneratedPage.content, /\[\[AI Agents\]\]/) +}) + +test('url ingest requires explicit confirmation', async () => { + const context = await createContext('url') + const pending = await ingestWikiSource( + { + title: 'Example Source', + sourceUrl: 'https://example.com/source', + }, + context, + ) + + assert.equal(pending.state, 'needs_confirmation') + assert.equal(pending.confirmationRequired, true) + + const confirmed = await ingestWikiSource( + { + title: 'Example Source', + sourceUrl: 'https://example.com/source', + content: 'Fetched example source body.', + confirmUrlIngest: true, + }, + context, + ) + assert.equal(confirmed.state, 'ingested') + + const contentOnlyUrl = await ingestWikiSource( + { + content: 'https://example.com/other-source', + }, + context, + ) + assert.equal(contentOnlyUrl.state, 'needs_confirmation') +}) + +test('write contexts automatically evolve wiki metadata after ingest and synthesis', async () => { + const context = await createContext('auto-evolve') + const writeContext = { ...context, autoEvolveOnWrite: true } + const created = await ingestWikiSource( + { + title: 'Auto Evolution Source', + content: 'Auto evolution keeps wiki health and related-page metadata current for agent evaluation notes.', + sourcePath: '/notes/auto-evolution.md', + }, + writeContext, + ) + + assert.equal(created.state, 'ingested') + assert.ok(created.evolve) + assert.ok(created.evolve.changedPageIds.includes(created.page!.id)) + const createdPage = await getWikiPage(created.page!.id, context) + assert.equal(createdPage.lifecycleState, 'evolved') + assert.equal(createdPage.evolveCheckedAt, created.evolve.evolvedAt) + assert.match(createdPage.frontmatter.evolveChangeSummary, /Evolution evidence initialized/) + + const synthesized = await synthesizeWiki( + { query: 'what do we know about auto evolution?', limit: 3 }, + writeContext, + ) + + assert.ok(synthesized.evolve) + assert.ok(synthesized.evolve.changedPageIds.includes(synthesized.page.id)) + const synthesisPage = await getWikiPage(synthesized.page.id, context) + assert.equal(synthesisPage.evolveCheckedAt, synthesized.evolve.evolvedAt) +}) + +test('lint flags orphan and missing linked pages, evolve records freshness', async () => { + const context = await createContext('lint') + const created = await ingestWikiSource( + { + title: 'Isolated Page', + content: 'This article references [[Missing Concept]] and has no backlinks.', + sourcePath: '/notes/isolated.md', + }, + context, + ) + assert.ok(created.page) + const current = await ingestWikiSource( + { + title: 'Standalone Page', + content: 'This article has no links.', + sourcePath: '/notes/standalone.md', + }, + context, + ) + + const lint = await lintWiki(context) + assert.ok(lint.issues.some((issue) => issue.kind === 'missing-link')) + assert.ok(lint.issues.some((issue) => issue.kind === 'orphan')) + const paths = resolveWikiPaths(context) + const lintConflicts = JSON.parse(await fs.readFile(paths.conflictsPath, 'utf8')) as Array<{ kind: string }> + assert.ok(lintConflicts.some((issue) => issue.kind === 'missing-link')) + assert.ok(!lintConflicts.some((issue) => issue.kind === 'orphan')) + assert.equal((await getWikiStatus(context)).conflictCount, 1) + + const detailBeforeEvolve = await getWikiPage(created.page!.id, context) + const rawBeforeEvolve = await fs.readFile(detailBeforeEvolve.path, 'utf8') + await fs.writeFile( + detailBeforeEvolve.path, + rawBeforeEvolve + .replace(/updatedAt: .+/, 'updatedAt: "2000-01-01T00:00:00.000Z"') + .replace(/memoryId: .+/, 'memoryId: 704468483663986688'), + 'utf8', + ) + + const evolved = await evolveWiki(context) + assert.equal(evolved.pageCount, 2) + assert.equal(evolved.staleCount, 1) + assert.equal(evolved.conflictCount, 2) + assert.deepEqual(evolved.related[created.page!.id], []) + assert.ok(evolved.changedPageIds.includes(created.page!.id)) + assert.ok(evolved.freshness[created.page!.id]) + assert.equal(evolved.freshness[current.page!.id].status, 'fresh') + + const evolvedPage = await getWikiPage(created.page!.id, context) + assert.equal(evolvedPage.freshnessStatus, 'stale') + assert.equal(evolvedPage.frontmatter.evolveChangedAt, evolved.evolvedAt) + assert.match(evolvedPage.frontmatter.evolveChangeSummary, /Freshness changed from fresh to stale/) + assert.equal(evolvedPage.evolveCheckedAt, evolved.evolvedAt) + assert.equal(evolvedPage.frontmatter.memoryId, '704468483663986688') + + const conflicts = JSON.parse(await fs.readFile(paths.conflictsPath, 'utf8')) as Array<{ kind: string }> + assert.ok(conflicts.some((issue) => issue.kind === 'missing-link')) + assert.ok(conflicts.some((issue) => issue.kind === 'stale')) + const related = JSON.parse(await fs.readFile(path.join(paths.metaRoot, 'related.json'), 'utf8')) as Record + assert.deepEqual(related[created.page!.id], []) +}) + +test('updated pages with evolve evidence keep the evolved lifecycle state', async () => { + const context = await createContext('lifecycle') + const created = await ingestWikiSource( + { + title: 'Updated Agent Page', + content: 'This updated page has enough content to be useful later.', + sourcePath: '/notes/updated-agent.md', + }, + context, + ) + assert.ok(created.page) + + const detail = await getWikiPage(created.page!.id, context) + const raw = await fs.readFile(detail.path, 'utf8') + await fs.writeFile( + detail.path, + raw + .replace(/createdAt: .+/, 'createdAt: "2026-01-01T00:00:00.000Z"') + .replace(/updatedAt: .+/, 'updatedAt: "2026-01-02T00:00:00.000Z"') + .replace(/freshnessScore: .+/, 'freshnessScore: 1') + .replace(/freshnessStatus: .+/, 'freshnessStatus: "fresh"') + .replace(/\n---\n/, '\nevolveChangedAt: "2026-01-03T00:00:00.000Z"\nevolveChangeSummary: "Related pages updated."\n---\n'), + 'utf8', + ) + + const evolved = await getWikiPage(created.page!.id, context) + assert.equal(evolved.lifecycleState, 'evolved') +}) diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts new file mode 100644 index 00000000..68337050 --- /dev/null +++ b/packages/backend/src/services/wikiService.ts @@ -0,0 +1,1683 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + addManagedMemory, + deleteManagedMemory, + listManagedMemories, + resolveManagedMemoryStoreContext, + searchManagedMemories, + type ManagedMemoryContext, +} from './managedMemory.js' +import { + getOpenclawPathModule, + getOpenclawProfileSelection, + type OpenclawProfileContext, + type OpenclawProfileSelection, +} from '../openclawProfile.js' + +export type WikiPageType = 'entity' | 'concept' | 'source' | 'synthesis' | 'process' +export type WikiFreshnessStatus = 'fresh' | 'aging' | 'stale' +export type WikiLifecycleState = 'just_ingested' | 'updated' | 'evolved' | 'outdated' +export type WikiIngestState = 'ingested' | 'updated' | 'skipped' | 'needs_confirmation' +export type WikiLintSeverity = 'info' | 'warning' | 'error' + +export interface WikiServiceContext extends OpenclawProfileContext { + profileSelection?: OpenclawProfileSelection + vaultRootOverride?: string + managedMemoryContext?: ManagedMemoryContext + autoEvolveOnWrite?: boolean +} + +export interface WikiStatusPayload { + profileKey: string + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string + pageCount: number + sourceCount: number + staleCount: number + conflictCount: number + memory: { + engine: string + storagePath: string + } +} + +export interface WikiPageSummary { + id: string + title: string + type: WikiPageType + path: string + relativePath: string + snippet: string + sourceCount: number + freshnessStatus: WikiFreshnessStatus + freshnessScore: number + lifecycleState: WikiLifecycleState + createdAt: string + updatedAt: string + evolvedAt: string + evolveCheckedAt: string + evolveChangedAt: string + evolveChangeSummary: string + evolveSource: string + lastAccessedAt: string + links: string[] + backlinks: string[] + memoryIds: string[] +} + +export interface WikiPageDetail extends WikiPageSummary { + content: string + frontmatter: Record + citations: WikiCitation[] +} + +export interface WikiCitation { + title: string + sourcePath?: string + sourceUrl?: string +} + +export interface WikiSearchResult extends WikiPageSummary { + score: number + matchType: 'keyword' | 'semantic' +} + +export interface WikiIngestInput { + title?: string + content?: string + sourceUrl?: string + sourcePath?: string + sourceType?: string + pageType?: WikiPageType + confirmUrlIngest?: boolean +} + +export interface WikiIngestPayload { + state: WikiIngestState + confirmationRequired: boolean + message: string + page?: WikiPageSummary + memoryId?: string + pagesCreated: number + pagesUpdated: number + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiQueryPayload { + query: string + usedWiki: boolean + answer: string + results: WikiSearchResult[] + citations: WikiCitation[] + offerToSave: boolean +} + +export type WikiAssistReason = 'explicit_wiki' | 'knowledge_question' | 'project_context' | 'not_relevant' + +export interface WikiAssistPayload extends WikiQueryPayload { + reason: WikiAssistReason +} + +export interface WikiSynthesizeInput { + query: string + title?: string + limit?: number +} + +export interface WikiSynthesizePayload { + title: string + query: string + page: WikiPageSummary + memoryId: string + pagesCreated: number + pagesUpdated: number + sourcePageIds: string[] + citations: WikiCitation[] + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiLintIssue { + id: string + severity: WikiLintSeverity + kind: 'orphan' | 'missing-link' | 'duplicate-title' | 'stale' | 'schema' + pageId?: string + title: string + detail: string +} + +export interface WikiLintPayload { + checkedAt: string + issueCount: number + issues: WikiLintIssue[] +} + +export interface WikiEvolvePayload { + evolvedAt: string + pageCount: number + staleCount: number + conflictCount: number + changedPageIds: string[] + related: Record + warnings: string[] + freshness: Record +} + +export type WikiLinkAction = 'ingest' | 'summarize_once' | 'current_conversation_only' + +export interface WikiLinkChoicePayload { + input: string + urls: string[] + requiresChoice: boolean + defaultAction: WikiLinkAction + actions: Array<{ + id: WikiLinkAction + label: string + description: string + }> + message: string +} + +interface WikiPaths { + profileKey: string + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string +} + +interface WikiIngestStateEntry { + fingerprint: string + pageId: string + memoryId: string + updatedAt: string +} + +interface WikiIngestStateFile { + version: 1 + sources: Record +} + +interface ParsedPage { + filePath: string + relativePath: string + frontmatter: Record + body: string +} + +type WikiFreshnessMeta = Record + +const PAGE_DIRS: Record = { + entity: 'entities', + concept: 'concepts', + source: 'sources', + synthesis: 'synthesis', + process: 'processes', +} + +const WIKI_STATE_FILE = 'ingest-state.json' +const DEFAULT_FRESHNESS_SCORE = 1 + +function nowIso(): string { + return new Date().toISOString() +} + +function getProfileKey(profileSelection: OpenclawProfileSelection): string { + if (profileSelection.kind === 'named' && profileSelection.name) return `named:${profileSelection.name}` + 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') + 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'), + } +} + +function statePath(paths: WikiPaths): string { + return path.join(paths.metaRoot, WIKI_STATE_FILE) +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.stat(targetPath) + return true + } catch { + return false + } +} + +async function writeIfMissing(filePath: string, content: string): Promise { + if (await pathExists(filePath)) return + await fs.writeFile(filePath, content, 'utf8') +} + +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\n\nPages use YAML frontmatter with id, title, type, source, freshness, and provenance fields.\n', + ) + await writeIfMissing(paths.freshnessPath, '{}\n') + await writeIfMissing(paths.conflictsPath, '[]\n') + await writeIfMissing(statePath(paths), `${JSON.stringify({ version: 1, sources: {} }, null, 2)}\n`) + return paths +} + +function slugify(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/['"]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return normalized || `wiki-${Date.now()}` +} + +function fingerprintSource(input: { + title: string + content: string + sourceUrl?: string + sourcePath?: string +}): string { + return createHash('sha256') + .update(input.title) + .update('\n') + .update(input.sourceUrl ?? '') + .update('\n') + .update(input.sourcePath ?? '') + .update('\n') + .update(input.content) + .digest('hex') +} + +function sourceKey(input: WikiIngestInput, title: string): string { + return input.sourceUrl?.trim() || input.sourcePath?.trim() || `title:${slugify(title)}` +} + +function looksLikeUrl(value: string | undefined): boolean { + return Boolean(value && /^https?:\/\//i.test(value.trim())) +} + +function extractHttpUrls(value: string): string[] { + const urls = new Set() + const pattern = /https?:\/\/[^\s<>"')\]]+/gi + let match: RegExpExecArray | null + while ((match = pattern.exec(value))) { + const normalized = match[0].replace(/[.,;:!?]+$/, '') + if (normalized) urls.add(normalized) + } + return [...urls] +} + +export function classifyWikiQuestion(query: string): { useWiki: boolean; reason: WikiAssistReason } { + const text = query.trim().toLowerCase() + if (text.length < 5) return { useWiki: false, reason: 'not_relevant' } + + if (/\b(wiki|knowledge base|kb|what do we know|what have we learned|known about)\b/i.test(text)) { + return { useWiki: true, reason: 'explicit_wiki' } + } + + if (/\b(prior|previous|earlier|saved|remembered|notes?|docs?|documentation|research|sources?|articles?|citations?|decision|decisions|rationale)\b/i.test(text)) { + return { useWiki: true, reason: 'knowledge_question' } + } + + if (/\b(project context|codebase context|architecture|design choice|implementation plan|roadmap|issue #?\d+|pr #?\d+)\b/i.test(text)) { + return { useWiki: true, reason: 'project_context' } + } + + return { useWiki: false, reason: 'not_relevant' } +} + +function inferTitle(input: WikiIngestInput): string { + if (input.title?.trim()) return input.title.trim() + if (input.sourceUrl?.trim()) { + try { + const url = new URL(input.sourceUrl.trim()) + return url.hostname + url.pathname.replace(/\/$/, '') + } catch { + return input.sourceUrl.trim() + } + } + if (input.sourcePath?.trim()) return path.basename(input.sourcePath.trim()) + const firstLine = input.content?.split(/\r?\n/).find((line) => line.trim())?.trim() + return firstLine?.replace(/^#+\s*/, '').slice(0, 80) || 'Untitled wiki article' +} + +function inferPageType(input: WikiIngestInput): WikiPageType { + return input.pageType ?? 'source' +} + +function normalizeContent(input: WikiIngestInput): string { + const content = input.content?.trim() + if (content) return content + if (input.sourceUrl?.trim()) return `Source URL: ${input.sourceUrl.trim()}` + return '' +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") +} + +function htmlToReadableText(html: string): { title?: string; text: string } { + const title = decodeHtmlEntities(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? '') + const text = decodeHtmlEntities( + html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<\/(p|div|section|article|header|footer|li|h[1-6]|tr)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n\s+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), + ) + return { title: title || undefined, text } +} + +async function fetchUrlContent(sourceUrl: string): Promise<{ title?: string; content: string }> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(sourceUrl, { + signal: controller.signal, + headers: { + accept: 'text/html,text/plain,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'user-agent': 'OpenClaw-Wiki/1.0', + }, + }) + if (!response.ok) { + throw new Error(`URL fetch failed with HTTP ${response.status}`) + } + const raw = (await response.text()).slice(0, 500_000) + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('html') || /]/i.test(raw)) { + const parsed = htmlToReadableText(raw) + return { + title: parsed.title, + content: parsed.text || `Source URL: ${sourceUrl}`, + } + } + return { content: raw.trim() || `Source URL: ${sourceUrl}` } + } finally { + clearTimeout(timeout) + } +} + +function renderFrontmatter(values: Record): string { + const lines = Object.entries(values) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => { + if (typeof value === 'number' || typeof value === 'boolean') return `${key}: ${value}` + return `${key}: ${JSON.stringify(String(value))}` + }) + return `---\n${lines.join('\n')}\n---\n` +} + +function parseFrontmatter(raw: string): { frontmatter: Record; body: string } { + if (!raw.startsWith('---')) return { frontmatter: {}, body: raw } + const end = raw.indexOf('\n---', 3) + if (end < 0) return { frontmatter: {}, body: raw } + const block = raw.slice(3, end).trim() + const body = raw.slice(end + 4).replace(/^\r?\n/, '') + const frontmatter: Record = {} + for (const line of block.split(/\r?\n/)) { + const index = line.indexOf(':') + if (index < 0) continue + 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 + } + } + return { frontmatter, body } +} + +function renderMarkdownWithFrontmatter( + frontmatter: Record, + body: string, +): string { + return `${renderFrontmatter(frontmatter)}\n${body.replace(/^\r?\n/, '')}` +} + +function extractWikiLinks(content: string): string[] { + const links = new Set() + const pattern = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g + let match: RegExpExecArray | null + while ((match = pattern.exec(content))) { + const link = match[1]?.trim() + if (link) links.add(link) + } + return [...links] +} + +function makeSnippet(content: string, query = ''): string { + const compact = content.replace(/\s+/g, ' ').trim() + if (!query.trim()) return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact + const lower = compact.toLowerCase() + const needle = query.trim().toLowerCase() + const index = lower.indexOf(needle) + if (index < 0) return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact + const start = Math.max(0, index - 70) + const end = Math.min(compact.length, index + needle.length + 90) + return `${start > 0 ? '...' : ''}${compact.slice(start, end)}${end < compact.length ? '...' : ''}` +} + +function freshnessStatus(score: number): WikiFreshnessStatus { + if (score < 0.35) return 'stale' + if (score < 0.7) return 'aging' + return 'fresh' +} + +function lifecycleState(frontmatter: Record, freshness: WikiFreshnessStatus): WikiLifecycleState { + if (freshness === 'stale') return 'outdated' + + if (frontmatter.evolveChangedAt) return 'evolved' + + const createdMs = Date.parse(frontmatter.createdAt || '') + const nowMs = Date.now() + if (Number.isFinite(createdMs) && nowMs - createdMs < 86_400_000) { + return 'just_ingested' + } + + const createdAt = frontmatter.createdAt || '' + const updatedAt = frontmatter.updatedAt || '' + if (createdAt && updatedAt && createdAt !== updatedAt) { + return 'updated' + } + + return 'updated' +} + +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function countSources(frontmatter: Record, body: string): number { + const explicitCount = frontmatter.sourceCount ? parseNumber(frontmatter.sourceCount, 0) : 0 + if (explicitCount > 0) return explicitCount + + const sources = new Set() + if (frontmatter.sourceUrl) sources.add(frontmatter.sourceUrl) + if (frontmatter.sourcePath) sources.add(frontmatter.sourcePath) + const sourceSection = body.match(/^## Sources\s*\n+([\s\S]*?)(?:\n##\s|\n#\s|$)/m)?.[1] ?? '' + for (const line of sourceSection.matchAll(/^(?:[-*]|\d+\.)\s+(.+)$/gm)) { + const source = line[1]?.trim() + if (source) sources.add(source) + } + return sources.size +} + +function parseCitations(frontmatter: Record): WikiCitation[] { + if (frontmatter.sourceUrls || frontmatter.sourcePaths) { + const urls = (frontmatter.sourceUrls ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const paths = (frontmatter.sourcePaths ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const titles = (frontmatter.sourceTitles ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const count = Math.max(urls.length, paths.length, titles.length) + return Array.from({ length: count }, (_, index) => ({ + title: titles[index] || urls[index] || paths[index] || 'Source', + sourceUrl: urls[index], + sourcePath: paths[index], + })).filter((citation) => citation.sourceUrl || citation.sourcePath) + } + const citation: WikiCitation = { + title: frontmatter.sourceTitle || frontmatter.title || 'Source', + sourcePath: frontmatter.sourcePath || undefined, + sourceUrl: frontmatter.sourceUrl || undefined, + } + return citation.sourcePath || citation.sourceUrl ? [citation] : [] +} + +async function readJsonFile(filePath: string, fallback: T): Promise { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as T + } catch { + return fallback + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +async function readIngestState(paths: WikiPaths): Promise { + const parsed = await readJsonFile(statePath(paths), { version: 1, sources: {} }) + return { + version: 1, + sources: parsed.sources && typeof parsed.sources === 'object' ? parsed.sources : {}, + } +} + +async function writeIngestState(paths: WikiPaths, state: WikiIngestStateFile): Promise { + await writeJsonFile(statePath(paths), state) +} + +async function collectMarkdownFiles(root: string, out: string[]): Promise { + let entries: Array + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const filePath = path.join(root, entry.name) + if (entry.isDirectory()) { + await collectMarkdownFiles(filePath, out) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(filePath) + } + } +} + +async function readParsedPages(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const files: string[] = [] + await collectMarkdownFiles(paths.pagesRoot, files) + const pages = await Promise.all( + files.map(async (filePath) => { + const raw = await fs.readFile(filePath, 'utf8') + const parsed = parseFrontmatter(raw) + return { + filePath, + relativePath: path.relative(paths.vaultRoot, filePath), + ...parsed, + } + }), + ) + return pages +} + +function pageTypeFromRelativePath(relativePath: string): WikiPageType { + const normalized = relativePath.replace(/\\/g, '/') + for (const [type, dir] of Object.entries(PAGE_DIRS) as Array<[WikiPageType, string]>) { + if (normalized.includes(`/pages/${dir}/`) || normalized.startsWith(`pages/${dir}/`)) return type + } + return 'source' +} + +function summarizeParsedPages(pages: ParsedPage[], query = '', freshnessMeta: WikiFreshnessMeta = {}): WikiPageSummary[] { + const titleToId = new Map() + for (const page of pages) { + const id = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) + const title = page.frontmatter.title || id + titleToId.set(title, id) + titleToId.set(id, id) + } + + 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)) { + const targetId = titleToId.get(link) ?? slugify(link) + if (!backlinksById.has(targetId)) backlinksById.set(targetId, new Set()) + backlinksById.get(targetId)!.add(fromId) + } + } + + return pages.map((page) => { + const id = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) + const title = page.frontmatter.title || id + const type = (page.frontmatter.type as WikiPageType | undefined) ?? pageTypeFromRelativePath(page.relativePath) + const meta = freshnessMeta[id] + const score = typeof meta?.score === 'number' + ? meta.score + : parseNumber(page.frontmatter.freshnessScore, DEFAULT_FRESHNESS_SCORE) + const resolvedFreshness = meta?.status ?? (page.frontmatter.freshnessStatus as WikiFreshnessStatus | undefined) ?? freshnessStatus(score) + const evolvedAt = page.frontmatter.evolveChangedAt || '' + return { + id, + title, + type, + path: page.filePath, + relativePath: page.relativePath, + snippet: makeSnippet(page.body.replace(/^# .+$/m, ''), query), + sourceCount: countSources(page.frontmatter, page.body), + freshnessStatus: resolvedFreshness, + freshnessScore: score, + lifecycleState: lifecycleState(page.frontmatter, resolvedFreshness), + createdAt: page.frontmatter.createdAt || '', + updatedAt: page.frontmatter.updatedAt || page.frontmatter.createdAt || '', + evolvedAt, + evolveCheckedAt: meta?.checkedAt || page.frontmatter.evolveCheckedAt || '', + evolveChangedAt: page.frontmatter.evolveChangedAt || '', + evolveChangeSummary: page.frontmatter.evolveChangeSummary || '', + evolveSource: page.frontmatter.evolveSource || '', + lastAccessedAt: meta?.lastAccessedAt || page.frontmatter.lastAccessedAt || '', + links: extractWikiLinks(page.body), + backlinks: [...(backlinksById.get(id) ?? new Set())], + memoryIds: page.frontmatter.memoryId ? [page.frontmatter.memoryId] : [], + } + }).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.title.localeCompare(right.title)) +} + +function renderWikiPage(input: { + id: string + title: string + type: WikiPageType + sourceType: string + sourcePath?: string + sourceUrl?: string + content: string + memoryId: string + fingerprint: string + createdAt: string + updatedAt: string +}): string { + const fm = renderFrontmatter({ + id: input.id, + title: input.title, + type: input.type, + sourceType: input.sourceType, + sourcePath: input.sourcePath, + sourceUrl: input.sourceUrl, + sourceTitle: input.title, + memoryId: input.memoryId, + fingerprint: input.fingerprint, + durability: 'durable', + category: input.type === 'process' ? 'procedure' : 'reference', + freshnessScore: DEFAULT_FRESHNESS_SCORE, + freshnessStatus: 'fresh', + sourceCount: input.sourcePath || input.sourceUrl ? 1 : 0, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }) + const sources = input.sourceUrl + ? `\n## Sources\n\n- ${input.sourceUrl}\n` + : input.sourcePath + ? `\n## Sources\n\n- ${input.sourcePath}\n` + : '' + return `${fm}\n# ${input.title}\n\n${input.content.trim()}\n${sources}` +} + +function renderGeneratedWikiPage(input: { + id: string + title: string + type: WikiPageType + content: string + memoryId: string + fingerprint: string + sourcePages: WikiPageDetail[] + createdAt: string + updatedAt: string +}): string { + const citations = input.sourcePages + .flatMap((page) => page.citations) + .filter((citation) => citation.sourceUrl || citation.sourcePath) + const fm = renderFrontmatter({ + id: input.id, + title: input.title, + type: input.type, + sourceType: 'synthesis', + sourceTitle: input.title, + sourceTitles: citations.map((citation) => citation.title).join('|'), + sourceUrls: citations.map((citation) => citation.sourceUrl ?? '').join('|'), + sourcePaths: citations.map((citation) => citation.sourcePath ?? '').join('|'), + memoryId: input.memoryId, + fingerprint: input.fingerprint, + durability: 'durable', + category: 'synthesis', + freshnessScore: DEFAULT_FRESHNESS_SCORE, + freshnessStatus: 'fresh', + sourceCount: citations.length || input.sourcePages.length, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }) + const sourceLines = input.sourcePages.map((page, index) => { + const citation = page.citations[0] + const source = citation?.sourceUrl || citation?.sourcePath || page.relativePath + return `${index + 1}. [[${page.title}]] - ${source}` + }) + return `${fm}\n# ${input.title}\n\n${input.content.trim()}\n\n## Sources\n\n${sourceLines.join('\n')}\n` +} + +async function writeIndex(paths: WikiPaths, pages: WikiPageSummary[]): Promise { + const grouped = new Map() + for (const page of pages) { + if (!grouped.has(page.type)) grouped.set(page.type, []) + grouped.get(page.type)!.push(page) + } + const sections = [...grouped.entries()].map(([type, items]) => { + const lines = items.map((page) => `- [[${page.title}]] - ${page.freshnessStatus}, ${page.updatedAt || 'unknown'}`) + return `## ${type}\n\n${lines.join('\n')}` + }) + await fs.writeFile(paths.indexPath, `# Wiki Index\n\n${sections.join('\n\n')}\n`, 'utf8') +} + +async function appendLog(paths: WikiPaths, line: string): Promise { + await fs.appendFile(paths.logPath, `- ${nowIso()} ${line}\n`, 'utf8') +} + +async function runAutoEvolveOnWrite( + context: WikiServiceContext, + warnings: string[], +): Promise { + if (!context.autoEvolveOnWrite) return undefined + try { + return await evolveWiki({ ...context, autoEvolveOnWrite: false }) + } catch { + warnings.push('auto_evolve_failed') + return undefined + } +} + +function managedContext(context: WikiServiceContext): ManagedMemoryContext { + return context.managedMemoryContext ?? { + profileSelection: context.profileSelection, + homeDir: context.homeDir, + platform: context.platform, + } +} + +export async function getWikiStatus(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const pages = summarizeParsedPages(await readParsedPages(context)) + const conflicts = await readJsonFile(paths.conflictsPath, []) + const memoryStore = resolveManagedMemoryStoreContext(managedContext(context)) + return { + profileKey: paths.profileKey, + 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, + pageCount: pages.length, + sourceCount: pages.reduce((sum, page) => sum + page.sourceCount, 0), + staleCount: pages.filter((page) => page.freshnessStatus === 'stale').length, + conflictCount: conflicts.length, + memory: { + engine: memoryStore.engine, + storagePath: memoryStore.storagePath, + }, + } +} + +export async function listWikiPages(context: WikiServiceContext = {}): Promise { + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + return summarizeParsedPages(await readParsedPages(context), '', freshnessMeta) +} + +export async function getWikiPage(pageId: string, context: WikiServiceContext = {}): Promise { + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + const pages = await readParsedPages(context) + const summaries = summarizeParsedPages(pages, '', freshnessMeta) + const summary = summaries.find((page) => page.id === pageId || slugify(page.title) === pageId) + if (!summary) throw new Error(`Wiki page not found: ${pageId}`) + const parsed = pages.find((page) => page.filePath === summary.path) + if (!parsed) throw new Error(`Wiki page file not found: ${pageId}`) + return { + ...summary, + content: parsed.body, + frontmatter: parsed.frontmatter, + citations: parseCitations(parsed.frontmatter), + } +} + +function keywordScore(page: WikiPageSummary, query: string, content: string): number { + const normalized = query.trim().toLowerCase() + if (!normalized) return 0 + const title = page.title.toLowerCase() + const haystack = `${title}\n${content.toLowerCase()}` + let score = 0 + if (title === normalized) score += 100 + if (title.includes(normalized)) score += 45 + if (haystack.includes(normalized)) score += 20 + for (const token of normalized.split(/[^a-z0-9._-]+/).filter(Boolean)) { + if (title.includes(token)) score += 12 + if (haystack.includes(token)) score += 4 + } + return score +} + +function numberFromUnknown(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN + return Number.isFinite(parsed) ? parsed : fallback +} + +function recordRankBoost(metadata: Record, page: WikiPageSummary): number { + const freshness = metadata.freshness && typeof metadata.freshness === 'object' + ? numberFromUnknown((metadata.freshness as Record).score, page.freshnessScore) + : page.freshnessScore + const quality = metadata.quality && typeof metadata.quality === 'object' + ? numberFromUnknown((metadata.quality as Record).confidence, 0.5) + : 0.5 + const accessCount = numberFromUnknown(metadata.access_count, 0) + const durabilityBoost = metadata.durability === 'durable' ? 6 : 0 + const sourceTypeBoost = metadata.sourceType === 'manual' ? 2 : metadata.sourceType === 'url' ? 1 : 0 + const scopeBoost = metadata.scope === 'wiki' ? 8 : 0 + return scopeBoost + durabilityBoost + sourceTypeBoost + freshness * 10 + quality * 8 + Math.log1p(accessCount) +} + +export async function searchWiki( + query: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const trimmed = query.trim() + if (!trimmed) return [] + const limit = Math.max(1, Math.min(50, options.limit ?? 12)) + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + const parsedPages = await readParsedPages(context) + const summaries = summarizeParsedPages(parsedPages, trimmed, freshnessMeta) + const contentByPath = new Map(parsedPages.map((page) => [page.filePath, page.body])) + + const results = new Map() + for (const page of summaries) { + const score = keywordScore(page, trimmed, contentByPath.get(page.path) ?? '') + if (score <= 0) continue + results.set(page.id, { + ...page, + score, + matchType: 'keyword', + }) + } + + const semanticHits = await searchManagedMemories(trimmed, { limit }, managedContext(context)).catch(() => []) + for (const hit of semanticHits) { + const metadata = hit.metadata ?? {} + const pageId = typeof metadata.pageId === 'string' ? metadata.pageId : '' + const page = pageId ? summaries.find((item) => item.id === pageId) : undefined + if (!page) continue + const semanticScore = Math.round((hit.score ?? 0) * 100 + recordRankBoost(metadata, page)) + const current = results.get(page.id) + if (!current || semanticScore > current.score) { + results.set(page.id, { + ...page, + score: semanticScore, + matchType: 'semantic', + }) + } + } + + return [...results.values()] + .sort((left, right) => right.score - left.score || right.freshnessScore - left.freshnessScore) + .slice(0, limit) +} + +export async function ingestWikiSource( + input: WikiIngestInput, + context: WikiServiceContext = {}, +): Promise { + const sourceUrlFromContent = !input.sourceUrl?.trim() && !input.sourcePath?.trim() && looksLikeUrl(input.content) + ? input.content!.trim() + : undefined + const ingestInput: WikiIngestInput = sourceUrlFromContent + ? { ...input, sourceUrl: sourceUrlFromContent, content: undefined, sourceType: input.sourceType ?? 'url' } + : input + const isUrl = looksLikeUrl(ingestInput.sourceUrl) + let title = inferTitle(ingestInput) + let content = normalizeContent(ingestInput) + const warnings: string[] = [] + if (isUrl && !ingestInput.confirmUrlIngest) { + return { + state: 'needs_confirmation', + confirmationRequired: true, + message: 'URL ingest requires explicit confirmation. Choose ingest, summarize once, or use for this conversation only.', + pagesCreated: 0, + pagesUpdated: 0, + warnings: ['url_ingest_requires_confirmation'], + } + } + + if (ingestInput.sourceUrl?.trim() && ingestInput.confirmUrlIngest && (!ingestInput.content?.trim() || content === `Source URL: ${ingestInput.sourceUrl.trim()}`)) { + try { + const fetched = await fetchUrlContent(ingestInput.sourceUrl.trim()) + content = fetched.content + if (!ingestInput.title?.trim() && fetched.title) title = fetched.title.slice(0, 120) + } catch { + warnings.push('url_fetch_failed') + content = `Source URL: ${ingestInput.sourceUrl.trim()}` + } + } + + if (!content) { + throw new Error('Wiki ingest requires content or a source URL') + } + + const paths = await ensureWikiVault(context) + const state = await readIngestState(paths) + const pageType = inferPageType(ingestInput) + const key = sourceKey(ingestInput, title) + const previous = state.sources[key] + const existingPage = previous?.pageId + ? (await listWikiPages(context)).find((item) => item.id === previous.pageId) + : undefined + const defaultPageId = `${PAGE_DIRS[pageType]}-${slugify(title)}` + const defaultPagePath = path.join(paths.pagesRoot, PAGE_DIRS[pageType], `${slugify(title)}.md`) + const pageId = existingPage?.id ?? previous?.pageId ?? defaultPageId + const pagePath = existingPage?.path ?? defaultPagePath + const fingerprint = fingerprintSource({ + title, + content, + sourceUrl: ingestInput.sourceUrl, + sourcePath: ingestInput.sourcePath, + }) + if (previous?.fingerprint === fingerprint && await pathExists(pagePath)) { + const page = (await listWikiPages(context)).find((item) => item.id === previous.pageId) + return { + state: 'skipped', + confirmationRequired: false, + message: 'Wiki source is already current.', + page, + memoryId: previous.memoryId, + pagesCreated: 0, + pagesUpdated: 0, + warnings: [], + } + } + + const now = nowIso() + const created = await addManagedMemory( + { + content, + metadata: { + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + scope: 'wiki', + durability: 'durable', + category: pageType === 'process' ? 'procedure' : 'reference', + provenance: { + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + sourcePath: ingestInput.sourcePath, + sourceUrl: ingestInput.sourceUrl, + sourceFingerprint: fingerprint, + importedAt: now, + createdBy: 'user', + }, + quality: { + confidence: 0.8, + recallPriority: pageType === 'source' ? 0.7 : 0.8, + }, + pageId, + sectionId: 'body', + freshness: { + score: DEFAULT_FRESHNESS_SCORE, + status: 'fresh', + updatedAt: now, + }, + lint: { + status: 'unchecked', + }, + }, + }, + managedContext(context), + ) + + if (previous?.memoryId && previous.memoryId !== created.memoryId) { + await deleteManagedMemory(previous.memoryId, managedContext(context)).catch(() => undefined) + } + + const existed = await pathExists(pagePath) + await fs.writeFile( + pagePath, + renderWikiPage({ + id: pageId, + title, + type: pageType, + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + sourcePath: ingestInput.sourcePath, + sourceUrl: ingestInput.sourceUrl, + content, + memoryId: created.memoryId, + fingerprint, + createdAt: existingPage?.createdAt || previous?.updatedAt || now, + updatedAt: now, + }), + 'utf8', + ) + state.sources[key] = { + fingerprint, + pageId, + memoryId: created.memoryId, + updatedAt: now, + } + await writeIngestState(paths, state) + const pages = await listWikiPages(context) + await writeIndex(paths, pages) + await appendLog(paths, `${existed ? 'Updated' : 'Created'} [[${title}]] from ${ingestInput.sourceUrl || ingestInput.sourcePath || 'manual input'}.`) + const evolve = await runAutoEvolveOnWrite(context, warnings) + const refreshedPages = evolve ? await listWikiPages(context) : pages + + return { + state: existed ? 'updated' : 'ingested', + confirmationRequired: false, + message: existed ? 'Wiki article updated.' : 'Wiki article created.', + page: refreshedPages.find((page) => page.id === pageId), + memoryId: created.memoryId, + pagesCreated: existed ? 0 : 1, + pagesUpdated: existed ? 1 : 0, + warnings, + evolve, + } +} + +function shouldUseWikiForQuestion(query: string): boolean { + return classifyWikiQuestion(query).useWiki +} + +export async function queryWiki( + query: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const trimmed = query.trim() + if (!trimmed) throw new Error('Wiki query is required') + const results = await searchWiki(trimmed, { limit: options.limit ?? 6 }, context) + const usedWiki = shouldUseWikiForQuestion(trimmed) || results.length > 0 + if (!usedWiki || results.length === 0) { + return { + query: trimmed, + usedWiki: false, + answer: 'No relevant wiki article was found. Use normal conversation context, or ingest sources into the Wiki first.', + results: [], + citations: [], + offerToSave: false, + } + } + + const citations: WikiCitation[] = [] + for (const result of results.slice(0, 3)) { + const page = await getWikiPage(result.id, context) + citations.push(...page.citations) + } + const top = results.slice(0, 3) + const answer = [ + `Wiki found ${results.length} relevant article${results.length === 1 ? '' : 's'}.`, + ...top.map((page, index) => `${index + 1}. [[${page.title}]] - ${page.snippet}`), + ].join('\n') + + return { + query: trimmed, + usedWiki: true, + answer, + results, + citations, + offerToSave: true, + } +} + +export async function assistWithWiki( + question: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const query = question.trim() + if (!query) throw new Error('Wiki assist question is required') + const decision = classifyWikiQuestion(query) + if (!decision.useWiki) { + return { + query, + usedWiki: false, + reason: decision.reason, + answer: 'Wiki context was not used because the question does not appear to require prior knowledge, docs, research, decisions, or project context.', + results: [], + citations: [], + offerToSave: false, + } + } + + return { + ...(await queryWiki(query, options, context)), + reason: decision.reason, + } +} + +export function planWikiLinkChoice(input: string): WikiLinkChoicePayload { + const text = input.trim() + const urls = extractHttpUrls(text) + const actions: WikiLinkChoicePayload['actions'] = [ + { + id: 'ingest', + label: 'Ingest into Wiki', + description: 'Fetch the URL, store provenance in managed PowerMem, and compile or update wiki markdown pages.', + }, + { + id: 'summarize_once', + label: 'Summarize once', + description: 'Use the URL for a one-time answer without writing it into durable Wiki knowledge.', + }, + { + id: 'current_conversation_only', + label: 'Use only now', + description: 'Use the link only as transient context for the current conversation.', + }, + ] + + return { + input: text, + urls, + requiresChoice: urls.length > 0, + defaultAction: 'current_conversation_only', + actions, + message: urls.length > 0 + ? 'A pasted link needs an explicit choice before Wiki ingestion.' + : 'No HTTP URL was detected.', + } +} + +function importantTokens(query: string): string[] { + const stopWords = new Set([ + 'about', + 'after', + 'again', + 'against', + 'all', + 'and', + 'are', + 'can', + 'for', + 'from', + 'how', + 'into', + 'know', + 'the', + 'this', + 'what', + 'when', + 'where', + 'wiki', + 'with', + ]) + return query + .toLowerCase() + .split(/[^a-z0-9]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 1 && !stopWords.has(token)) +} + +function titleCaseTopic(value: string): string { + const cleaned = value + .replace(/\bwhat\s+do\s+we\s+know\s+about\b/gi, '') + .replace(/\bwiki\b/gi, '') + .replace(/[?!.,:;]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + const topic = cleaned || value.trim() || 'Wiki Synthesis' + return topic.split(/\s+/).map((word) => { + const upper = word.toUpperCase() + if (['AI', 'API', 'LLM', 'MCP', 'RAG'].includes(upper)) return upper + return `${word.slice(0, 1).toUpperCase()}${word.slice(1).toLowerCase()}` + }).join(' ') +} + +function splitSentences(content: string): string[] { + return content + .replace(/^# .+$/gm, ' ') + .replace(/\s+/g, ' ') + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter((sentence) => sentence.length >= 50 && sentence.length <= 360) +} + +function scoreSentence(sentence: string, tokens: string[]): number { + const lower = sentence.toLowerCase() + return tokens.reduce((score, token) => score + (lower.includes(token) ? 1 : 0), 0) +} + +function selectSynthesisBullets(sourcePages: WikiPageDetail[], query: string): string[] { + const tokens = importantTokens(query) + const candidates = sourcePages.flatMap((page) => ( + splitSentences(page.content).map((sentence) => ({ + sentence, + page, + score: scoreSentence(sentence, tokens), + })) + )) + const seen = new Set() + return candidates + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score || left.sentence.length - right.sentence.length) + .filter((candidate) => { + const key = candidate.sentence.toLowerCase().replace(/[^a-z0-9]+/g, ' ').slice(0, 120) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .slice(0, 6) + .map((candidate) => `- ${candidate.sentence} ([[${candidate.page.title}]])`) +} + +function renderSynthesisBody(input: { + title: string + query: string + sourcePages: WikiPageDetail[] +}): string { + const bullets = selectSynthesisBullets(input.sourcePages, input.query) + const sourceNotes = input.sourcePages.map((page) => `- [[${page.title}]]: ${page.snippet}`) + return [ + '## Generated Synthesis', + '', + `This page synthesizes Wiki sources for: **${input.query}**.`, + '', + '## Key Points', + '', + ...(bullets.length > 0 ? bullets : ['- The available sources were relevant but did not expose enough extractable focused claims for a stronger synthesis.']), + '', + '## Source Notes', + '', + ...sourceNotes, + ].join('\n') +} + +export async function synthesizeWiki( + input: WikiSynthesizeInput, + context: WikiServiceContext = {}, +): Promise { + const query = input.query.trim() + if (!query) throw new Error('Wiki synthesis query is required') + const limit = Math.max(1, Math.min(8, input.limit ?? 5)) + const results = await searchWiki(query, { limit }, context) + const title = titleCaseTopic(input.title?.trim() || query) + const pageId = `synthesis-${slugify(title)}` + const sourceResults = results.filter((result) => result.id !== pageId) + if (sourceResults.length === 0) throw new Error('Wiki synthesis requires at least one matching source') + + const sourcePages = await Promise.all(sourceResults.slice(0, limit).map((result) => getWikiPage(result.id, context))) + const paths = await ensureWikiVault(context) + const pagePath = path.join(paths.pagesRoot, PAGE_DIRS.synthesis, `${slugify(title)}.md`) + const previous = await pathExists(pagePath) ? await getWikiPage(pageId, context).catch(() => null) : null + const content = renderSynthesisBody({ title, query, sourcePages }) + const fingerprint = fingerprintSource({ + title, + content, + sourcePath: sourcePages.map((page) => page.id).join('|'), + }) + const now = nowIso() + const created = await addManagedMemory( + { + content, + metadata: { + sourceType: 'synthesis', + scope: 'wiki', + durability: 'durable', + category: 'synthesis', + provenance: { + sourceType: 'synthesis', + sourcePageIds: sourcePages.map((page) => page.id), + sourceFingerprint: fingerprint, + importedAt: now, + createdBy: 'wiki-synthesis', + }, + quality: { + confidence: 0.72, + recallPriority: 0.85, + }, + pageId, + sectionId: 'body', + freshness: { + score: DEFAULT_FRESHNESS_SCORE, + status: 'fresh', + updatedAt: now, + }, + lint: { + status: 'unchecked', + }, + }, + }, + managedContext(context), + ) + if (previous?.memoryIds[0] && previous.memoryIds[0] !== created.memoryId) { + await deleteManagedMemory(previous.memoryIds[0], managedContext(context)).catch(() => undefined) + } + + const existed = await pathExists(pagePath) + await fs.writeFile( + pagePath, + renderGeneratedWikiPage({ + id: pageId, + title, + type: 'synthesis', + content, + memoryId: created.memoryId, + fingerprint, + sourcePages, + createdAt: previous?.frontmatter.createdAt || now, + updatedAt: now, + }), + 'utf8', + ) + const pages = await listWikiPages(context) + await writeIndex(paths, pages) + await appendLog(paths, `${existed ? 'Updated' : 'Created'} generated synthesis [[${title}]] from ${sourcePages.length} source page(s).`) + const citations = sourcePages.flatMap((sourcePage) => sourcePage.citations) + const warnings: string[] = citations.length === 0 ? ['synthesis_has_no_source_citations'] : [] + const evolve = await runAutoEvolveOnWrite(context, warnings) + const refreshedPages = evolve ? await listWikiPages(context) : pages + const page = refreshedPages.find((item) => item.id === pageId) + if (!page) throw new Error(`Generated wiki page was not indexed: ${pageId}`) + return { + title, + query, + page, + memoryId: created.memoryId, + pagesCreated: existed ? 0 : 1, + pagesUpdated: existed ? 1 : 0, + sourcePageIds: sourcePages.map((sourcePage) => sourcePage.id), + citations, + warnings, + evolve, + } +} + +export async function lintWiki(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const parsed = await readParsedPages(context) + const pages = summarizeParsedPages(parsed) + const issues = computeWikiLintIssues(parsed, pages) + + await writeJsonFile(paths.conflictsPath, issues.filter(isWikiConflictIssue)) + return { + checkedAt: nowIso(), + issueCount: issues.length, + issues, + } +} + +function isWikiConflictIssue(issue: WikiLintIssue): boolean { + return issue.kind === 'duplicate-title' || issue.kind === 'missing-link' || issue.kind === 'stale' +} + +function computeWikiLintIssues(parsed: ParsedPage[], pages: WikiPageSummary[]): WikiLintIssue[] { + const issues: WikiLintIssue[] = [] + const titleCounts = new Map() + for (const page of pages) { + const key = page.title.toLowerCase() + if (!titleCounts.has(key)) titleCounts.set(key, []) + titleCounts.get(key)!.push(page) + + if (page.links.length === 0 && page.backlinks.length === 0) { + issues.push({ + id: `orphan:${page.id}`, + severity: 'warning', + kind: 'orphan', + pageId: page.id, + title: 'Orphan page', + detail: `${page.title} has no wiki links or backlinks.`, + }) + } + if (page.freshnessStatus === 'stale') { + issues.push({ + id: `stale:${page.id}`, + severity: 'warning', + kind: 'stale', + pageId: page.id, + title: 'Stale page', + detail: `${page.title} has a stale freshness score.`, + }) + } + } + + const titles = new Set(pages.map((page) => page.title).concat(pages.map((page) => page.id))) + for (const page of pages) { + for (const link of page.links) { + if (titles.has(link)) continue + issues.push({ + id: `missing:${page.id}:${slugify(link)}`, + severity: 'warning', + kind: 'missing-link', + pageId: page.id, + title: 'Missing linked page', + detail: `${page.title} links to missing page ${link}.`, + }) + } + } + + for (const [title, matches] of titleCounts.entries()) { + if (matches.length < 2) continue + issues.push({ + id: `duplicate:${slugify(title)}`, + severity: 'error', + kind: 'duplicate-title', + title: 'Duplicate title', + detail: `${matches.length} wiki pages share the title ${matches[0]!.title}.`, + }) + } + + for (const parsedPage of parsed) { + const title = parsedPage.frontmatter.title + const id = parsedPage.frontmatter.id + if (!title || !id) { + issues.push({ + id: `schema:${parsedPage.relativePath}`, + severity: 'error', + kind: 'schema', + title: 'Schema violation', + detail: `${parsedPage.relativePath} is missing required id or title frontmatter.`, + }) + } + } + + return issues +} + +export async function evolveWiki(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const parsed = await readParsedPages(context) + const evolvedAt = nowIso() + const freshness: WikiEvolvePayload['freshness'] = {} + const changedPageIds: string[] = [] + const related: Record = {} + + for (const page of parsed) { + const updatedAt = page.frontmatter.updatedAt || evolvedAt + const lastAccessedAt = page.frontmatter.lastAccessedAt || updatedAt + const updatedMs = Date.parse(updatedAt) + const evolvedMs = Date.parse(evolvedAt) + const ageDays = Number.isFinite(updatedMs) + ? Math.max(0, ((Number.isFinite(evolvedMs) ? evolvedMs : Date.now()) - updatedMs) / 86_400_000) + : 0 + const importance = parseNumber(page.frontmatter.importance, 0.7) + const decayRate = 0.16 * (1 - importance * 0.8) + const score = Math.max(0, Math.min(1, importance * Math.exp(-decayRate * ageDays))) + const roundedScore = Number(score.toFixed(4)) + const status = ageDays < 1 ? 'fresh' : freshnessStatus(roundedScore) + const id = page.frontmatter.id || slugify(page.frontmatter.title || page.relativePath) + freshness[id] = { + score: roundedScore, + status, + lastAccessedAt, + updatedAt, + checkedAt: evolvedAt, + } + related[id] = [] + } + + const summaries = summarizeParsedPages(parsed) + const summaryById = new Map(summaries.map((page) => [page.id, page])) + for (const page of summaries) { + const linked = new Set() + for (const link of page.links.concat(page.backlinks)) { + const target = summaryById.get(link) ?? summaries.find((candidate) => candidate.title === link) + if (target && target.id !== page.id) linked.add(target.id) + } + related[page.id] = [...linked].sort() + } + + const currentIssues = computeWikiLintIssues(parsed, summaries) + const conflicts = currentIssues.filter(isWikiConflictIssue) + const issuesByPage = new Map() + for (const issue of conflicts) { + if (!issue.pageId) continue + issuesByPage.set(issue.pageId, [...(issuesByPage.get(issue.pageId) ?? []), issue]) + } + + for (const page of parsed) { + const id = page.frontmatter.id || slugify(page.frontmatter.title || page.relativePath) + const item = freshness[id] + if (!item) continue + const previousScore = parseNumber(page.frontmatter.freshnessScore, DEFAULT_FRESHNESS_SCORE) + const previousStatus = page.frontmatter.freshnessStatus + const previousRelated = page.frontmatter.relatedPageIds || '' + const nextRelated = (related[id] ?? []).join('|') + const pageIssues = issuesByPage.get(id) ?? [] + const previousIssueCount = parseNumber(page.frontmatter.evolveIssueCount, 0) + const changes: string[] = [] + + if (previousStatus && previousStatus !== item.status) { + changes.push(`Freshness changed from ${previousStatus} to ${item.status}.`) + } else if (!previousStatus) { + changes.push(`Freshness initialized as ${item.status}.`) + } + if (Math.abs(previousScore - item.score) >= 0.01) { + changes.push(`Freshness score changed from ${Number(previousScore.toFixed(4))} to ${item.score}.`) + } + if (previousRelated !== nextRelated) { + changes.push(nextRelated ? `Related pages updated: ${nextRelated}.` : 'Related pages cleared.') + } + if (previousIssueCount !== pageIssues.length) { + changes.push(pageIssues.length > 0 ? `Health issues detected: ${pageIssues.map((issue) => issue.kind).join(', ')}.` : 'Health issues cleared.') + } + if (!page.frontmatter.evolveSource) { + changes.push('Evolution evidence initialized.') + } + + const changedByEvolve = changes.length > 0 + const nextFrontmatter = { + ...page.frontmatter, + freshnessScore: item.score, + freshnessStatus: item.status, + lastAccessedAt: item.lastAccessedAt, + relatedPageIds: nextRelated, + evolveIssueCount: pageIssues.length, + evolveSource: 'freshness-score, wiki-links, backlinks, lint-health', + ...(changedByEvolve + ? { + evolveChangedAt: evolvedAt, + evolveChangeSummary: changes.join(' '), + } + : {}), + } + if ( + changedByEvolve || + Math.abs(previousScore - item.score) >= 0.01 || + page.frontmatter.freshnessStatus !== item.status || + page.frontmatter.relatedPageIds !== nextRelated || + previousIssueCount !== pageIssues.length + ) { + await fs.writeFile(page.filePath, renderMarkdownWithFrontmatter(nextFrontmatter, page.body), 'utf8') + if (changedByEvolve) changedPageIds.push(id) + } + } + + const evolvedParsed = await readParsedPages(context) + const evolvedPages = summarizeParsedPages(evolvedParsed, '', freshness) + const evolvedIssues = computeWikiLintIssues(evolvedParsed, evolvedPages) + const evolvedConflicts = evolvedIssues.filter(isWikiConflictIssue) + await writeJsonFile(paths.freshnessPath, freshness) + await writeJsonFile(paths.conflictsPath, evolvedConflicts) + await writeJsonFile(path.join(paths.metaRoot, 'related.json'), related) + await writeIndex(paths, evolvedPages) + await appendLog(paths, `Evolved ${Object.keys(freshness).length} wiki page(s); ${changedPageIds.length} markdown page(s) updated.`) + + return { + evolvedAt, + pageCount: Object.keys(freshness).length, + staleCount: Object.values(freshness).filter((item) => item.status === 'stale').length, + conflictCount: evolvedConflicts.length, + changedPageIds, + related, + warnings: evolvedConflicts.length > 0 ? ['wiki_conflicts_detected'] : [], + freshness, + } +} + +export async function recentWikiManagedMemories( + limit = 20, + context: WikiServiceContext = {}, +) { + const listed = await listManagedMemories({ limit }, managedContext(context)) + return listed.memories.filter((memory) => typeof memory.metadata.pageId === 'string') +} From addadeea738dfdf5a67aa3837d64247a4c991d77 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Wed, 29 Apr 2026 21:56:58 +0800 Subject: [PATCH 10/27] feat(web): add wiki workspace module --- .../src/app/__tests__/moduleRegistry.test.ts | 6 + packages/web/src/app/iconRegistry.ts | 2 + packages/web/src/app/navigationMeta.ts | 3 +- packages/web/src/lib/types.ts | 171 ++++ packages/web/src/locales/main/en.ts | 99 ++ packages/web/src/locales/main/ja.ts | 99 ++ packages/web/src/locales/main/zh.ts | 99 ++ packages/web/src/modules/wiki/WikiPage.tsx | 853 ++++++++++++++++++ .../modules/wiki/__tests__/WikiPage.test.tsx | 281 ++++++ packages/web/src/modules/wiki/index.ts | 16 + packages/web/src/shared/adapters/wiki.ts | 111 +++ 11 files changed, 1739 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/modules/wiki/WikiPage.tsx create mode 100644 packages/web/src/modules/wiki/__tests__/WikiPage.test.tsx create mode 100644 packages/web/src/modules/wiki/index.ts create mode 100644 packages/web/src/shared/adapters/wiki.ts diff --git a/packages/web/src/app/__tests__/moduleRegistry.test.ts b/packages/web/src/app/__tests__/moduleRegistry.test.ts index e5039928..f3acd1e6 100644 --- a/packages/web/src/app/__tests__/moduleRegistry.test.ts +++ b/packages/web/src/app/__tests__/moduleRegistry.test.ts @@ -49,6 +49,7 @@ describe('moduleRegistry', () => { expect(ids).toContain('mcp') expect(ids).toContain('channels') expect(ids).toContain('memory') + expect(ids).toContain('wiki') expect(ids).toContain('sessions') expect(ids).toContain('models') expect(ids).toContain('ocr') @@ -75,4 +76,9 @@ describe('moduleRegistry', () => { expect(['main', 'manage', 'system', undefined]).toContain(m.group) } }) + + it('places wiki immediately after memory in nav order', () => { + const ids = modules.map((m) => m.id) + expect(ids.indexOf('wiki')).toBe(ids.indexOf('memory') + 1) + }) }) diff --git a/packages/web/src/app/iconRegistry.ts b/packages/web/src/app/iconRegistry.ts index 62241b65..4e0c925f 100644 --- a/packages/web/src/app/iconRegistry.ts +++ b/packages/web/src/app/iconRegistry.ts @@ -1,6 +1,7 @@ import { ArrowUpCircle, BarChart3, + BookOpen, BookOpenText, BookText, Bot, @@ -40,6 +41,7 @@ const ICON_MAP: Record = { 'settings-2': Settings2, 'file-text': FileText, 'book-text': BookText, + 'book-open': BookOpen, 'book-open-text': BookOpenText, 'scroll-text': ScrollText, wrench: Wrench, diff --git a/packages/web/src/app/navigationMeta.ts b/packages/web/src/app/navigationMeta.ts index 40f2d8fa..283d9e24 100644 --- a/packages/web/src/app/navigationMeta.ts +++ b/packages/web/src/app/navigationMeta.ts @@ -21,7 +21,7 @@ export const NAV_SECTIONS: NavSectionMeta[] = [ id: 'workspace', labelKey: 'layout.section.workspace', descriptionKey: 'layout.section.workspaceDesc', - paths: ['/channels', '/models', '/agents', '/memory'], + paths: ['/channels', '/models', '/agents', '/memory', '/wiki'], }, { id: 'extend', @@ -47,6 +47,7 @@ export const PAGE_META: Record = { '/models': { sectionId: 'workspace', descriptionKey: 'layout.page.models' }, '/agents': { sectionId: 'workspace', descriptionKey: 'layout.page.agents' }, '/memory': { sectionId: 'workspace', descriptionKey: 'layout.page.memory' }, + '/wiki': { sectionId: 'workspace', descriptionKey: 'layout.page.wiki' }, '/ocr': { sectionId: 'extend', descriptionKey: 'layout.page.ocr' }, '/capabilities': { sectionId: 'extend', descriptionKey: 'layout.page.capabilities' }, '/skills': { sectionId: 'extend', descriptionKey: 'layout.page.skills' }, diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 78620621..b756ef05 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -444,6 +444,177 @@ export interface ManagedMemoryImportStatusPayload { lastRun: ManagedMemoryImportRunSummary | null } +export type WikiPageType = 'entity' | 'concept' | 'source' | 'synthesis' | 'process' +export type WikiFreshnessStatus = 'fresh' | 'aging' | 'stale' +export type WikiLifecycleState = 'just_ingested' | 'updated' | 'evolved' | 'outdated' +export type WikiIngestState = 'ingested' | 'updated' | 'skipped' | 'needs_confirmation' +export type WikiLintSeverity = 'info' | 'warning' | 'error' + +export interface WikiStatusPayload { + profileKey: string + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string + pageCount: number + sourceCount: number + staleCount: number + conflictCount: number + memory: { + engine: string + storagePath: string + } +} + +export interface WikiCitation { + title: string + sourcePath?: string + sourceUrl?: string +} + +export interface WikiPageSummary { + id: string + title: string + type: WikiPageType + path: string + relativePath: string + snippet: string + sourceCount: number + freshnessStatus: WikiFreshnessStatus + freshnessScore: number + lifecycleState: WikiLifecycleState + createdAt: string + updatedAt: string + evolvedAt: string + evolveCheckedAt: string + evolveChangedAt: string + evolveChangeSummary: string + evolveSource: string + lastAccessedAt: string + links: string[] + backlinks: string[] + memoryIds: string[] +} + +export interface WikiPageDetail extends WikiPageSummary { + content: string + frontmatter: Record + citations: WikiCitation[] +} + +export interface WikiSearchResult extends WikiPageSummary { + score: number + matchType: 'keyword' | 'semantic' +} + +export interface WikiIngestInput { + title?: string + content?: string + sourceUrl?: string + sourcePath?: string + sourceType?: string + pageType?: WikiPageType + confirmUrlIngest?: boolean +} + +export interface WikiIngestPayload { + state: WikiIngestState + confirmationRequired: boolean + message: string + page?: WikiPageSummary + memoryId?: string + pagesCreated: number + pagesUpdated: number + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiQueryPayload { + query: string + usedWiki: boolean + answer: string + results: WikiSearchResult[] + citations: WikiCitation[] + offerToSave: boolean +} + +export type WikiAssistReason = 'explicit_wiki' | 'knowledge_question' | 'project_context' | 'not_relevant' + +export interface WikiAssistPayload extends WikiQueryPayload { + reason: WikiAssistReason +} + +export interface WikiSynthesizeInput { + query: string + title?: string + limit?: number +} + +export interface WikiSynthesizePayload { + title: string + query: string + page: WikiPageSummary + memoryId: string + pagesCreated: number + pagesUpdated: number + sourcePageIds: string[] + citations: WikiCitation[] + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiLintIssue { + id: string + severity: WikiLintSeverity + kind: 'orphan' | 'missing-link' | 'duplicate-title' | 'stale' | 'schema' + pageId?: string + title: string + detail: string +} + +export interface WikiLintPayload { + checkedAt: string + issueCount: number + issues: WikiLintIssue[] +} + +export interface WikiEvolvePayload { + evolvedAt: string + pageCount: number + staleCount: number + conflictCount: number + changedPageIds: string[] + related: Record + warnings: string[] + freshness: Record +} + +export type WikiLinkAction = 'ingest' | 'summarize_once' | 'current_conversation_only' + +export interface WikiLinkChoicePayload { + input: string + urls: string[] + requiresChoice: boolean + defaultAction: WikiLinkAction + actions: Array<{ + id: WikiLinkAction + label: string + description: string + }> + message: string +} + export interface ChannelInfo { id: string name: string diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index 5bb965f1..3ac9c706 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -1153,6 +1153,7 @@ export default { "nav.mcp": "MCP Servers", "nav.channels": "Channels", "nav.memory": "Memory", + "nav.wiki": "Wiki", "nav.sessions": "Sessions", "nav.models": "Models", "nav.skills": "Skills", @@ -1955,4 +1956,102 @@ 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.", + "wiki.kicker": "Workspace knowledge", + "wiki.title": "Wiki", + "wiki.subtitle": "Search, inspect, ingest, and maintain compiled articles backed by managed PowerMem.", + "wiki.statPages": "Articles", + "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.statEngine": "Memory engine", + "wiki.searchTitle": "Article Search", + "wiki.searchPlaceholder": "Search articles 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.ingestTitle": "Ingest Source", + "wiki.ingestTitlePlaceholder": "Article title", + "wiki.ingestSourcePlaceholder": "Source URL or file path", + "wiki.ingestContentPlaceholder": "Markdown or notes to compile into the wiki", + "wiki.ingest": "Ingest", + "wiki.ingestFailed": "Wiki ingest failed.", + "wiki.urlConfirmation": "URL ingest is explicit. Confirm before writing this link into the wiki.", + "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.backlinks": "Backlinks", + "wiki.links": "Links", + "wiki.citations": "Citations", + "wiki.none": "None", + "wiki.queryTitle": "Ask Wiki", + "wiki.queryPlaceholder": "Ask a question against compiled knowledge", + "wiki.ask": "Ask", + "wiki.queryFailed": "Wiki query failed.", + "wiki.offerToSave": "This answer can be saved as durable wiki knowledge after confirmation.", + "wiki.saveSynthesis": "Save synthesis", + "wiki.synthesizeFailed": "Wiki synthesis failed.", + "wiki.synthesisSaved": "Saved synthesis: {{title}}", + "wiki.runLint": "Run Lint", + "wiki.runEvolve": "Run Evolve", + "wiki.lintFailed": "Wiki lint failed.", + "wiki.evolveFailed": "Wiki evolve failed.", + "wiki.lintSummary": "{{count}} issue(s) found.", + "wiki.evolveSummary": "Updated freshness for {{pages}} article(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.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.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.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.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.bonusIssues": "Health signals", + "wiki.bonusIssuesHint": "Missing links, stale pages, or duplicates found automatically.", + "wiki.sourceCount": "{{count}} source(s)", + "wiki.type.entity": "Entity", + "wiki.type.concept": "Concept", + "wiki.type.source": "Source", + "wiki.type.synthesis": "Synthesis", + "wiki.type.process": "Process", + "wiki.freshness.fresh": "Fresh", + "wiki.freshness.aging": "Aging", + "wiki.freshness.stale": "Stale", + "wiki.lifecycle.just_ingested": "Just ingested", + "wiki.lifecycle.updated": "Updated", + "wiki.lifecycle.evolved": "Evolved", + "wiki.lifecycle.outdated": "Outdated", + "wiki.signal.freshnessTitle": "Freshness score: {{score}}%", + "wiki.signal.lifecycleTitle": "Article lifecycle", + "wiki.signal.changedAt": "Evolved {{time}}", + "wiki.signal.ingestedAt": "Ingested", + "wiki.signal.updatedAt": "Updated", + "wiki.signal.evolvedAtLabel": "Evolve checked", + "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.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.source": "Ingested source", + "wiki.origin.sourceDetail": "Captured from a URL, file, or note with provenance. It remains inspectable as a source article.", + "wiki.origin.maintained": "Maintained page", + "wiki.origin.maintainedDetail": "Tracked by Wiki 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 fc2f6836..3375f953 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -1153,6 +1153,7 @@ export default { "nav.mcp": "MCPサーバー", "nav.channels": "チャンネル", "nav.memory": "メモリ", + "nav.wiki": "Wiki", "nav.sessions": "セッション", "nav.models": "モデル", "nav.skills": "スキル", @@ -1955,4 +1956,102 @@ export default { "cron.scheduleCronValue": "Cron {{value}}", "cron.scheduleEveryValue": "{{value}} ごと", "cron.scheduleAtValue": "{{value}} に実行", + "layout.page.wiki": "蓄積された知識を閲覧し、ソースを確認し、Wiki の保守を実行します。", + "wiki.kicker": "ワークスペース知識", + "wiki.title": "Wiki", + "wiki.subtitle": "管理 PowerMem を基盤にした記事を検索、閲覧、取り込み、保守します。", + "wiki.statPages": "記事", + "wiki.statSources": "ソース", + "wiki.statStale": "古い記事", + "wiki.statStaleHint": "古い記事は、Wiki の Evolve が算出した鮮度スコアがしきい値を下回った状態です。通常は長期間更新されていない、ソースが古い可能性がある、または確認すべきヘルスシグナルがあることを示します。", + "wiki.statEngine": "メモリエンジン", + "wiki.searchTitle": "記事検索", + "wiki.searchPlaceholder": "記事を検索、または既知のことを質問...", + "wiki.search": "検索", + "wiki.clearSearch": "検索をクリア", + "wiki.emptyPages": "Wiki 記事はまだありません。", + "wiki.searchFailed": "Wiki 検索に失敗しました。", + "wiki.loadFailed": "Wiki の読み込みに失敗しました。", + "wiki.ingestTitle": "ソース取り込み", + "wiki.ingestTitlePlaceholder": "記事タイトル", + "wiki.ingestSourcePlaceholder": "ソース URL またはファイルパス", + "wiki.ingestContentPlaceholder": "Wiki に編み込む Markdown またはメモ", + "wiki.ingest": "取り込み", + "wiki.ingestFailed": "Wiki 取り込みに失敗しました。", + "wiki.urlConfirmation": "URL の取り込みは明示的な確認が必要です。確認後に Wiki へ書き込みます。", + "wiki.confirmIngest": "URL を取り込む", + "wiki.summarizeOnce": "一度だけ要約", + "wiki.currentConversationOnly": "今回だけ使用", + "wiki.summarizeOnceNotice": "このリンクは一度だけの要約に使われ、Wiki には保存されません。", + "wiki.currentConversationOnlyNotice": "このリンクは現在の会話だけで使われ、Wiki には保存されません。", + "wiki.selectPage": "Wiki 記事を選択してください。", + "wiki.backlinks": "バックリンク", + "wiki.links": "リンク", + "wiki.citations": "引用", + "wiki.none": "なし", + "wiki.queryTitle": "Wiki に質問", + "wiki.queryPlaceholder": "蓄積済み知識に質問する", + "wiki.ask": "質問", + "wiki.queryFailed": "Wiki クエリに失敗しました。", + "wiki.offerToSave": "確認後、この回答を永続的な Wiki 知識として保存できます。", + "wiki.saveSynthesis": "統合ページを保存", + "wiki.synthesizeFailed": "Wiki 統合生成に失敗しました。", + "wiki.synthesisSaved": "統合ページを保存しました: {{title}}", + "wiki.runLint": "Lint を実行", + "wiki.runEvolve": "Evolve を実行", + "wiki.lintFailed": "Wiki Lint に失敗しました。", + "wiki.evolveFailed": "Wiki Evolve に失敗しました。", + "wiki.lintSummary": "{{count}} 件の問題が見つかりました。", + "wiki.evolveSummary": "{{pages}} 件の記事の鮮度を更新しました。古い記事: {{stale}} 件。", + "wiki.evolveDetails": "{{changed}} 件のページを書き換え、{{conflicts}} 件の競合、{{related}} 件の関連マップを更新しました。", + "wiki.evolveChangedEvidence": "{{count}} 件のページに新しい Evolve 根拠が追加されました。ページを開くと変更内容を確認できます。", + "wiki.evolveCheckedOnly": "Evolve はコーパスを確認しました。記事改善またはメタデータ変更は不要でした。", + "wiki.warning.conflictsDetected": "Wiki ヘルスチェックで {{count}} 件の競合が見つかりました。多くは不足している Wiki リンク、古いページ、重複タイトルです。Lint を実行すると詳細を確認できます。", + "wiki.warning.autoEvolveFailed": "自動 Evolve が完了しませんでした。手動で Evolve を実行して再試行できます。", + "wiki.evolution.changedTitle": "Evolve による改善あり", + "wiki.evolution.changedDetail": "Wiki は {{time}} に記事またはメタデータの改善を記録しました。", + "wiki.evolution.checkedTitle": "Evolve による確認済み", + "wiki.evolution.checkedDetail": "Wiki は {{time}} にこのページを確認し、記事本文はそのまま残しました。", + "wiki.bonusTitle": "Wiki がすでに代行した作業", + "wiki.bonusSubtitle": "手動の整理、タグ付け、読み直しなしで、ソースから自動作成または保守された価値です。", + "wiki.bonusGenerated": "LLM 統合ページ", + "wiki.bonusGeneratedHint": "生記事ではなく、Wiki が複数ソースから生成したページです。", + "wiki.bonusSources": "検索可能なソースページ", + "wiki.bonusSourcesHint": "後続の回答で参照できるよう、出典付きで保存されたソースです。", + "wiki.bonusChecked": "自動保守ページ", + "wiki.bonusCheckedHint": "{{changed}} 件の保守更新。{{evolved}} 件の真の Evolve 記事。", + "wiki.bonusIssues": "健全性シグナル", + "wiki.bonusIssuesHint": "欠落リンク、古いページ、重複を自動検出します。", + "wiki.sourceCount": "{{count}} 件のソース", + "wiki.type.entity": "エンティティ", + "wiki.type.concept": "コンセプト", + "wiki.type.source": "ソース", + "wiki.type.synthesis": "統合", + "wiki.type.process": "プロセス", + "wiki.freshness.fresh": "新鮮", + "wiki.freshness.aging": "経過中", + "wiki.freshness.stale": "古い", + "wiki.lifecycle.just_ingested": "取り込み直後", + "wiki.lifecycle.updated": "更新済み", + "wiki.lifecycle.evolved": "Evolve 済み", + "wiki.lifecycle.outdated": "古い", + "wiki.signal.freshnessTitle": "鮮度スコア: {{score}}%", + "wiki.signal.lifecycleTitle": "記事ライフサイクル", + "wiki.signal.changedAt": "Evolve {{time}}", + "wiki.signal.ingestedAt": "取り込み日時", + "wiki.signal.updatedAt": "更新日時", + "wiki.signal.evolvedAtLabel": "Evolve 確認日時", + "wiki.signal.changedAtLabel": "Evolve メタデータ変更日時", + "wiki.signal.noEvolveChange": "Evolve による変更記録なし", + "wiki.signal.evidence": "Evolve の根拠", + "wiki.signal.checkedOnly": "鮮度、リンク、健全性を確認しました。記事改善は記録されていません。", + "wiki.signal.evidenceSource": "根拠ソース: {{source}}", + "wiki.origin.llm": "LLM 統合", + "wiki.origin.llmDetail": "Wiki が {{count}} 件のソースシグナルから生成しました。複数の記事を再利用できる回答面に変える追加価値です。", + "wiki.origin.source": "取り込みソース", + "wiki.origin.sourceDetail": "URL、ファイル、メモから出典付きで取り込まれました。ソース記事として確認できます。", + "wiki.origin.maintained": "保守対象ページ", + "wiki.origin.maintainedDetail": "Wiki が鮮度、リンク、バックリンク、健全性シグナルを追跡しています。", + "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 f10c9681..76300716 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -1153,6 +1153,7 @@ export default { "nav.mcp": "MCP 服务", "nav.channels": "通道", "nav.memory": "记忆", + "nav.wiki": "Wiki", "nav.sessions": "会话", "nav.models": "模型", "nav.skills": "技能", @@ -1955,4 +1956,102 @@ export default { "cron.scheduleCronValue": "Cron {{value}}", "cron.scheduleEveryValue": "每 {{value}}", "cron.scheduleAtValue": "于 {{value}} 执行", + "layout.page.wiki": "浏览沉淀后的知识,检查来源,并运行 Wiki 维护。", + "wiki.kicker": "工作区知识", + "wiki.title": "Wiki", + "wiki.subtitle": "搜索、查看、写入并维护由托管 PowerMem 支撑的编译文章。", + "wiki.statPages": "文章", + "wiki.statSources": "来源", + "wiki.statStale": "过期", + "wiki.statStaleHint": "过期表示 Wiki 进化计算出的新鲜度分数低于阈值。通常是内容太久未更新、来源可能已变旧,或巡检发现需要复核的健康信号。", + "wiki.statEngine": "记忆引擎", + "wiki.searchTitle": "文章搜索", + "wiki.searchPlaceholder": "搜索文章,或询问我们知道什么...", + "wiki.search": "搜索", + "wiki.clearSearch": "清除搜索", + "wiki.emptyPages": "还没有 Wiki 文章。", + "wiki.searchFailed": "Wiki 搜索失败。", + "wiki.loadFailed": "Wiki 加载失败。", + "wiki.ingestTitle": "写入来源", + "wiki.ingestTitlePlaceholder": "文章标题", + "wiki.ingestSourcePlaceholder": "来源 URL 或文件路径", + "wiki.ingestContentPlaceholder": "要编译进 Wiki 的 Markdown 或笔记", + "wiki.ingest": "写入", + "wiki.ingestFailed": "Wiki 写入失败。", + "wiki.urlConfirmation": "URL 写入必须显式确认。确认后才会把这个链接写入 Wiki。", + "wiki.confirmIngest": "写入 URL", + "wiki.summarizeOnce": "仅总结一次", + "wiki.currentConversationOnly": "仅本轮使用", + "wiki.summarizeOnceNotice": "该链接只用于一次性总结,不会保存到 Wiki。", + "wiki.currentConversationOnlyNotice": "该链接只保留在当前对话,不会保存到 Wiki。", + "wiki.selectPage": "请选择一篇 Wiki 文章。", + "wiki.backlinks": "反向链接", + "wiki.links": "链接", + "wiki.citations": "引用", + "wiki.none": "无", + "wiki.queryTitle": "询问 Wiki", + "wiki.queryPlaceholder": "基于已沉淀知识提问", + "wiki.ask": "提问", + "wiki.queryFailed": "Wiki 查询失败。", + "wiki.offerToSave": "确认后可将这个回答保存为持久 Wiki 知识。", + "wiki.saveSynthesis": "保存综合页", + "wiki.synthesizeFailed": "Wiki 综合生成失败。", + "wiki.synthesisSaved": "已保存综合页:{{title}}", + "wiki.runLint": "运行巡检", + "wiki.runEvolve": "运行进化", + "wiki.lintFailed": "Wiki 巡检失败。", + "wiki.evolveFailed": "Wiki 进化失败。", + "wiki.lintSummary": "发现 {{count}} 个问题。", + "wiki.evolveSummary": "已更新 {{pages}} 篇文章的新鲜度;{{stale}} 篇过期。", + "wiki.evolveDetails": "已重写 {{changed}} 个页面;{{conflicts}} 个冲突;{{related}} 条关联映射。", + "wiki.evolveChangedEvidence": "{{count}} 个页面获得新的进化证据。打开页面可查看具体变化。", + "wiki.evolveCheckedOnly": "进化已检查整个语料;没有需要记录的文章改进或元数据变化。", + "wiki.warning.conflictsDetected": "Wiki 健康检查发现 {{count}} 个冲突,通常是缺失 Wiki 链接、过期页或重复标题。运行巡检可查看详情。", + "wiki.warning.autoEvolveFailed": "自动进化没有完成;可手动运行进化重试。", + "wiki.evolution.changedTitle": "进化已改进", + "wiki.evolution.changedDetail": "Wiki 在 {{time}} 记录了文章或元数据改进。", + "wiki.evolution.checkedTitle": "进化已检查", + "wiki.evolution.checkedDetail": "Wiki 在 {{time}} 检查了此页面,并保持文章内容不变。", + "wiki.bonusTitle": "Wiki 已经替你完成的工作", + "wiki.bonusSubtitle": "这些是系统从你的来源自动创建或维护的价值,不需要你手动归档、打标签或重新阅读。", + "wiki.bonusGenerated": "LLM 综合页", + "wiki.bonusGeneratedHint": "由 Wiki 跨来源生成,不是原始文章。", + "wiki.bonusSources": "可搜索来源页", + "wiki.bonusSourcesHint": "已保存来源和出处,可被后续问答引用。", + "wiki.bonusChecked": "自动维护页", + "wiki.bonusCheckedHint": "{{changed}} 次维护更新;{{evolved}} 篇文章真正进化。", + "wiki.bonusIssues": "健康信号", + "wiki.bonusIssuesHint": "自动发现缺失链接、过期页或重复项。", + "wiki.sourceCount": "{{count}} 个来源", + "wiki.type.entity": "实体", + "wiki.type.concept": "概念", + "wiki.type.source": "来源", + "wiki.type.synthesis": "综合", + "wiki.type.process": "流程", + "wiki.freshness.fresh": "新鲜", + "wiki.freshness.aging": "衰减中", + "wiki.freshness.stale": "过期", + "wiki.lifecycle.just_ingested": "刚写入", + "wiki.lifecycle.updated": "已更新", + "wiki.lifecycle.evolved": "已进化", + "wiki.lifecycle.outdated": "已过期", + "wiki.signal.freshnessTitle": "新鲜度分数:{{score}}%", + "wiki.signal.lifecycleTitle": "文章生命周期", + "wiki.signal.changedAt": "进化于 {{time}}", + "wiki.signal.ingestedAt": "写入时间", + "wiki.signal.updatedAt": "更新时间", + "wiki.signal.evolvedAtLabel": "进化检查时间", + "wiki.signal.changedAtLabel": "进化元数据变更时间", + "wiki.signal.noEvolveChange": "未记录进化变更", + "wiki.signal.evidence": "进化证据", + "wiki.signal.checkedOnly": "已检查新鲜度、链接和健康状态;未记录文章改进。", + "wiki.signal.evidenceSource": "证据来源:{{source}}", + "wiki.origin.llm": "LLM 综合", + "wiki.origin.llmDetail": "由 Wiki 基于 {{count}} 个来源信号生成。这是额外收益层:把多篇文章变成一个可复用回答面。", + "wiki.origin.source": "写入来源", + "wiki.origin.sourceDetail": "来自 URL、文件或笔记,并保留出处。它仍可作为来源文章被检查。", + "wiki.origin.maintained": "托管页面", + "wiki.origin.maintainedDetail": "由 Wiki 跟踪新鲜度、链接、反向链接和健康信号。", + "wiki.match.keyword": "关键词", + "wiki.match.semantic": "语义", } as const diff --git a/packages/web/src/modules/wiki/WikiPage.tsx b/packages/web/src/modules/wiki/WikiPage.tsx new file mode 100644 index 00000000..f035c8ed --- /dev/null +++ b/packages/web/src/modules/wiki/WikiPage.tsx @@ -0,0 +1,853 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + AlertTriangle, + BookOpen, + CheckCircle2, + CircleHelp, + FileText, + GitBranch, + History, + Link2, + LoaderCircle, + RefreshCw, + Search, + ShieldCheck, + Sparkles, + UploadCloud, + X, +} from 'lucide-react' +import type { + WikiEvolvePayload, + WikiIngestPayload, + WikiLintPayload, + WikiPageDetail, + WikiPageSummary, + WikiQueryPayload, + WikiSearchResult, + WikiStatusPayload, + WikiSynthesizePayload, +} from '@/lib/types' +import { + wikiEvolveResult, + wikiIngestResult, + wikiLintResult, + wikiPageResult, + wikiPagesResult, + wikiQueryResult, + wikiSearchResult, + wikiStatusResult, + wikiSynthesizeResult, +} from '@/shared/adapters/wiki' + +function statusClass(status: string): string { + if (status === 'fresh') return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + if (status === 'aging') return 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + return 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' +} + +function lifecycleClass(state: string): string { + if (state === 'just_ingested') return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300' + if (state === 'evolved') return 'border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300' + if (state === 'outdated') return 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' + return 'border-border bg-muted/30 text-muted-foreground' +} + +function hasEvolveChange(page: WikiPageSummary): boolean { + return page.lifecycleState === 'evolved' && Boolean(page.evolveChangedAt || page.evolveChangeSummary) +} + +function shortDate(value: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function evolveWarningText(warning: string, evolve: WikiEvolvePayload, t: (key: string, values?: Record) => string): string { + if (warning === 'wiki_conflicts_detected') { + return t('wiki.warning.conflictsDetected', { count: evolve.conflictCount }) + } + if (warning === 'auto_evolve_failed') { + return t('wiki.warning.autoEvolveFailed') + } + return warning +} + +function PageSignalChips({ page, compact = false }: { page: WikiPageSummary; compact?: boolean }) { + const { t } = useTranslation() + return ( +
+ + {t(`wiki.freshness.${page.freshnessStatus}`)} + + + {t(`wiki.lifecycle.${page.lifecycleState}`)} + + {!compact && hasEvolveChange(page) && page.evolvedAt ? ( + + {t('wiki.signal.changedAt', { time: shortDate(page.evolvedAt) })} + + ) : null} +
+ ) +} + +function pageOriginKey(page: WikiPageSummary): string { + if (page.type === 'synthesis') return 'wiki.origin.llm' + if (page.type === 'source') return 'wiki.origin.source' + return 'wiki.origin.maintained' +} + +function PageOriginBanner({ page }: { page: WikiPageDetail }) { + const { t } = useTranslation() + const isSynthesis = page.type === 'synthesis' + const isSource = page.type === 'source' + return ( +
+

{t(pageOriginKey(page))}

+

+ {isSynthesis + ? t('wiki.origin.llmDetail', { count: page.sourceCount }) + : isSource + ? t('wiki.origin.sourceDetail') + : t('wiki.origin.maintainedDetail')} +

+
+ ) +} + +function PageEvolutionBanner({ page }: { page: WikiPageDetail }) { + const { t } = useTranslation() + const changedByEvolve = hasEvolveChange(page) + return ( +
+

+ {changedByEvolve ? t('wiki.evolution.changedTitle') : t('wiki.evolution.checkedTitle')} +

+

+ {changedByEvolve + ? t('wiki.evolution.changedDetail', { time: shortDate(page.evolveChangedAt || page.evolvedAt || page.updatedAt) }) + : t('wiki.evolution.checkedDetail', { time: shortDate(page.evolveCheckedAt || page.updatedAt) })} +

+

+ {changedByEvolve && page.evolveChangeSummary ? page.evolveChangeSummary : t('wiki.signal.checkedOnly')} +

+ {page.evolveSource ? ( +

{t('wiki.signal.evidenceSource', { source: page.evolveSource })}

+ ) : null} +
+ ) +} + +function isLikelyUrl(value: string): boolean { + return /^https?:\/\//i.test(value.trim()) +} + +function MarkdownPreview({ + content, + onOpenLink, +}: { + content: string + onOpenLink: (target: string) => void +}) { + const lines = content.split(/\r?\n/) + return ( +
+ {lines.map((line, index) => { + const heading = line.match(/^(#{1,3})\s+(.+)$/) + if (heading) { + const size = heading[1].length === 1 ? 'text-xl' : heading[1].length === 2 ? 'text-lg' : 'text-base' + return

{heading[2]}

+ } + if (!line.trim()) return
+ const parts: Array<{ text: string; link?: string }> = [] + const pattern = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g + let cursor = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(line))) { + if (match.index > cursor) parts.push({ text: line.slice(cursor, match.index) }) + const link = match[1]?.trim() || '' + parts.push({ text: link, link }) + cursor = match.index + match[0].length + } + if (cursor < line.length) parts.push({ text: line.slice(cursor) }) + return ( +

+ {parts.map((part, partIndex) => part.link ? ( + + ) : ( + {part.text} + ))} +

+ ) + })} +
+ ) +} + +function PageList({ + pages, + selectedId, + onSelect, +}: { + pages: Array + selectedId: string | null + onSelect: (pageId: string) => void +}) { + const { t } = useTranslation() + if (pages.length === 0) { + return

{t('wiki.emptyPages')}

+ } + return ( +
    + {pages.map((page) => ( +
  • + +
  • + ))} +
+ ) +} + +function PageDetailModal({ + page, + onClose, + onOpenLink, +}: { + page: WikiPageDetail | null + onClose: () => void + onOpenLink: (target: string) => void +}) { + const { t } = useTranslation() + if (!page) return null + const changedByEvolve = hasEvolveChange(page) + + return ( +
+ + ) +} + +export default function WikiPage() { + const { t } = useTranslation() + const [status, setStatus] = useState(null) + const [pages, setPages] = useState([]) + const [selectedPage, setSelectedPage] = useState(null) + const [searchText, setSearchText] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [queryText, setQueryText] = useState('') + const [queryResult, setQueryResult] = useState(null) + const [synthesisResult, setSynthesisResult] = useState(null) + const [ingestTitle, setIngestTitle] = useState('') + const [ingestSource, setIngestSource] = useState('') + const [ingestContent, setIngestContent] = useState('') + const [pendingUrlInput, setPendingUrlInput] = useState(null) + const [linkChoiceNotice, setLinkChoiceNotice] = useState(null) + const [searchSubmitted, setSearchSubmitted] = useState(false) + const [lintResult, setLintResult] = useState(null) + const [evolveResult, setEvolveResult] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(null) + const [error, setError] = useState(null) + + const visiblePages = searchSubmitted ? searchResults : pages + const bonusStats = useMemo(() => { + const generated = pages.filter((page) => page.type === 'synthesis').length + const sources = pages.filter((page) => page.type === 'source').length + const checked = pages.filter((page) => Boolean(page.evolveCheckedAt)).length + const maintenanceChanged = pages.filter((page) => Boolean(page.evolveChangedAt || page.evolveChangeSummary)).length + const evolved = pages.filter(hasEvolveChange).length + return { generated, sources, checked, maintenanceChanged, evolved } + }, [pages]) + + const loadWiki = useCallback(async () => { + setLoading(true) + setError(null) + const [statusRes, pagesRes] = await Promise.all([wikiStatusResult(), wikiPagesResult()]) + if (!statusRes.success || !statusRes.data) setError(statusRes.error || t('wiki.loadFailed')) + else setStatus(statusRes.data) + if (!pagesRes.success) setError(pagesRes.error || t('wiki.loadFailed')) + else setPages(pagesRes.data ?? []) + setLoading(false) + }, [t]) + + const openPage = useCallback(async (pageId: string) => { + setActionLoading(`page:${pageId}`) + setError(null) + const result = await wikiPageResult(pageId) + if (result.success && result.data) { + setSelectedPage(result.data) + setDetailOpen(true) + } else { + const matched = pages.find((page) => page.title === pageId || page.id === pageId) + if (matched) { + const retry = await wikiPageResult(matched.id) + if (retry.success && retry.data) { + setSelectedPage(retry.data) + setDetailOpen(true) + } + else setError(retry.error || t('wiki.loadFailed')) + } else { + setError(result.error || t('wiki.loadFailed')) + } + } + setActionLoading(null) + }, [pages, t]) + + useEffect(() => { + void loadWiki() + }, [loadWiki]) + + async function handleSearch() { + const query = searchText.trim() + setActionLoading('search') + setError(null) + if (!query) { + setSearchResults([]) + setSearchSubmitted(false) + setActionLoading(null) + return + } + const result = await wikiSearchResult(query, { limit: 20 }) + if (result.success) { + setSearchSubmitted(true) + setSearchResults(result.data ?? []) + } + else setError(result.error || t('wiki.searchFailed')) + setActionLoading(null) + } + + async function submitIngest(confirmUrlIngest = false) { + setActionLoading('ingest') + setError(null) + setPendingUrlInput(null) + setLinkChoiceNotice(null) + const source = ingestSource.trim() + const result = await wikiIngestResult({ + title: ingestTitle.trim() || undefined, + content: ingestContent.trim() || (isLikelyUrl(source) ? undefined : source), + sourceUrl: isLikelyUrl(source) ? source : undefined, + sourcePath: source && !isLikelyUrl(source) ? source : undefined, + sourceType: isLikelyUrl(source) ? 'url' : source ? 'file' : 'manual', + pageType: 'source', + confirmUrlIngest, + }) + if (result.success && result.data) { + if (result.data.confirmationRequired) { + setPendingUrlInput(result.data) + } else { + if (result.data.evolve) setEvolveResult(result.data.evolve) + await loadWiki() + if (result.data.page) await openPage(result.data.page.id) + } + } else { + setError(result.error || t('wiki.ingestFailed')) + } + setActionLoading(null) + } + + async function handleQuery() { + const query = queryText.trim() + if (!query) return + setActionLoading('query') + setError(null) + const result = await wikiQueryResult(query, { limit: 6 }) + if (result.success && result.data) { + setQueryResult(result.data) + setSynthesisResult(null) + } + else setError(result.error || t('wiki.queryFailed')) + setActionLoading(null) + } + + async function handleSynthesize() { + const query = (queryResult?.query || queryText).trim() + if (!query) return + setActionLoading('synthesize') + setError(null) + const result = await wikiSynthesizeResult({ query, limit: 5 }) + if (result.success && result.data) { + setSynthesisResult(result.data) + if (result.data.evolve) setEvolveResult(result.data.evolve) + await loadWiki() + await openPage(result.data.page.id) + } else { + setError(result.error || t('wiki.synthesizeFailed')) + } + setActionLoading(null) + } + + async function handleLint() { + setActionLoading('lint') + setError(null) + const result = await wikiLintResult() + if (result.success && result.data) { + setLintResult(result.data) + await loadWiki() + } else { + setError(result.error || t('wiki.lintFailed')) + } + setActionLoading(null) + } + + async function handleEvolve() { + setActionLoading('evolve') + setError(null) + const result = await wikiEvolveResult() + if (result.success && result.data) { + setEvolveResult(result.data) + await loadWiki() + } else { + setError(result.error || t('wiki.evolveFailed')) + } + setActionLoading(null) + } + + const stats = useMemo(() => [ + { label: t('wiki.statPages'), value: String(status?.pageCount ?? 0), icon: FileText }, + { label: t('wiki.statSources'), value: String(status?.sourceCount ?? 0), icon: Link2 }, + { label: t('wiki.statStale'), value: String(status?.staleCount ?? 0), icon: AlertTriangle, hint: t('wiki.statStaleHint') }, + { label: t('wiki.statEngine'), value: status?.memory.engine ?? '-', icon: ShieldCheck }, + ], [status, t]) + + return ( +
+
+
+

{t('wiki.kicker')}

+

{t('wiki.title')}

+

{t('wiki.subtitle')}

+
+ +
+ + {error ?

{error}

: null} + +
+ {stats.map((item) => { + const Icon = item.icon + return ( +
+ +
+

+ {item.label} + {'hint' in item && item.hint ? ( + + + + {item.hint} + + + ) : null} +

+

{item.value}

+
+
+ ) + })} +
+ +
+
+
+ +

{t('wiki.bonusTitle')}

+
+

{t('wiki.bonusSubtitle')}

+
+
+
+

{bonusStats.generated}

+

{t('wiki.bonusGenerated')}

+

{t('wiki.bonusGeneratedHint')}

+
+
+

{bonusStats.sources}

+

{t('wiki.bonusSources')}

+

{t('wiki.bonusSourcesHint')}

+
+
+

{bonusStats.checked}

+

{t('wiki.bonusChecked')}

+

+ {t('wiki.bonusCheckedHint', { + changed: bonusStats.maintenanceChanged, + evolved: bonusStats.evolved, + })} +

+
+
+

{status?.conflictCount ?? 0}

+

{t('wiki.bonusIssues')}

+

{t('wiki.bonusIssuesHint')}

+
+
+
+ +
+
+
+ +

{t('wiki.ingestTitle')}

+
+ setIngestTitle(event.target.value)} + placeholder={t('wiki.ingestTitlePlaceholder')} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" + /> + setIngestSource(event.target.value)} + placeholder={t('wiki.ingestSourcePlaceholder')} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" + /> +