#!/usr/bin/env bun /** * RecallNest CLI — MCP-native memory search and distillation * * Usage: * lm search "query keywords" * lm ingest --source cc --limit 5 * lm ingest --source all * lm stats */ import { Command } from "commander"; import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; import { metaDir } from "./compat.js"; import { PACKAGE_VERSION } from "./version.js"; import type { RetrievalResult } from "./retriever.js"; import { applyRetrievalProfile, listRetrievalProfiles } from "./retrieval-profiles.js"; import { distillResults, formatExplainResults, formatSearchResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js"; import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsset, getExportsDir, listDirtyBriefAssets, listMemoryAssets, listPinAssets, pinSummaryLine, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js"; import { indexAsset, indexPinnedAsset } from "./asset-sync.js"; import { createComponents, createStoreOnly, expandHome, loadConfig, loadDotEnv, resolveDbPath, type LocalMemoryConfig } from "./runtime-config.js"; import { KGStore } from "./kg-store.js"; import { isKGModeEnabled } from "./kg-extractor.js"; import { formatDedupReasonSummary, getDedupSkipRate, type IngestResult, ingestCCTranscripts, ingestCodexSessions, ingestGeminiSessions, ingestKimiSessions, ingestMarkdownFiles, ingestObsidianVault, ingestConnectorFile, } from "./ingest.js"; import { runDoctor, formatDoctorResults } from "./doctor.js"; import { persistCaseMemory, persistMemory, persistWorkflowPattern } from "./capture-engine.js"; import { scanMemoryPromotions, scanSynthesisPromotions, buildPromoteScanDeps, formatPromoteScanResult, formatSynthesisPromoteResult, } from "./memory-promotion.js"; import { runDream, formatDreamResult, formatDreamMetrics, DEFAULT_DREAM_CONFIG, classifyDreamFailure, shouldBlockDreamRun, partitionAutoDreamScopes } from "./dream-pipeline.js"; import { dreamBudgetMs } from "./env-config.js"; import { listScopesAboveThreshold, pruneWriteCounts } from "./activity-counter.js"; import { isTranscriptScope } from "./memory-boundaries.js"; import { maybeRunGc } from "./auto-gc.js"; import { CaseMemoryInputSchema, type CaseMemoryInput, StoreMemoryInputSchema, type StoreMemoryInput, WorkflowPatternInputSchema, type WorkflowPatternInput, } from "./memory-schema.js"; import { classifyLegacyScope, type MemoryEntry } from "./store.js"; import { ConflictStatusSchema } from "./conflict-schema.js"; import { resolveConflictCandidate } from "./conflict-engine.js"; import { escalateConflicts } from "./conflict-escalation.js"; import { ConflictCandidateStore } from "./conflict-store.js"; import { createAuditLogger } from "./audit-log.js"; import { formatConflictAudit, formatConflictAuditMarkdown, formatConflictClusters, formatConflictEscalation, formatConflictList, formatConflictRecord, formatConflictResolution } from "./conflict-output.js"; import { CONFLICT_ATTENTION_LEVELS, parseConflictAttention, summarizeConflictLifecycle } from "./conflict-lifecycle.js"; import { buildConflictAuditSummary, clusterConflicts, summarizeConflictAdvice } from "./conflict-advisor.js"; import { WorkflowObservationOutcomeSchema } from "./workflow-observation-schema.js"; import { buildWorkflowEvidence, buildWorkflowObservationRecord, inspectWorkflowDashboard, inspectWorkflowHealth } from "./workflow-observation-engine.js"; import { formatWorkflowEvidencePack, formatWorkflowHealthDashboard, formatWorkflowHealthReport, formatWorkflowObservationSaved } from "./workflow-observation-output.js"; import { WorkflowObservationStore } from "./workflow-observation-store.js"; import { applyUtilityToMeta, computeMemoryUtility, formatMemoryUtility } from "./memory-utility.js"; import { applyWorkflowObservationScopeSuggestions, formatWorkflowObservationScopeReview, keepWorkflowObservationScopesGlobal, parseWorkflowObservationScopeCue, reviewWorkflowObservationScopes, } from "./workflow-observation-scope-review.js"; import { collectScopeInventory, formatScopeInventoryReport } from "./scope-inventory.js"; import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js"; import { createScopeSuggester } from "./scope-suggester.js"; /** * Auto-detect Claude Code transcript directories. * Scans ~/.claude/projects/ for ALL subdirectories containing .jsonl files. * * CC 按 cwd 编码 project 子目录,凡是不在 ~ 下跑的会话(Codex spawn 的子进程 / cd 到 repo 跑 / * bridge 临时工作目录)会落到 ~/.claude/projects/-XXX/ 别的子目录。旧版只挑 jsonl 最多的根 * 目录返回,导致 28+ 个子目录全失声。修复:返回所有有 jsonl 的子目录,调用方循环 ingest。 */ function detectCCPath(): string[] { const projectsDir = join(homedir(), ".claude", "projects"); if (!existsSync(projectsDir)) return []; try { return readdirSync(projectsDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => join(projectsDir, d.name)) .filter(dir => { try { return readdirSync(dir).some(f => f.endsWith(".jsonl")); } catch { return false; } }); } catch { return []; } } /** * Pick the CC project subdirectory with the most jsonl files (heuristic: user's primary cwd). * Used by memory source which still expects a single directory. */ function pickPrimaryCCPath(detected: string[]): string { let best = detected[0]; let bestCount = 0; for (const dir of detected) { try { const c = readdirSync(dir).filter(f => f.endsWith(".jsonl")).length; if (c > bestCount) { bestCount = c; best = dir; } } catch { /* skip */ } } return best; } /** * Resolve "auto" paths in config to actual directories. * * Note: cc 分支只返回第一个 detect 到的目录(fallback),ingest 入口已改为直接调 * detectCCPath() 拿全量子目录循环处理。这里保留是为兼容潜在的其它调用方。 */ function resolveSourcePath(source: string, key: string): string { if (source !== "auto") return expandHome(source); if (key === "cc") { const detected = detectCCPath(); if (detected.length === 0) throw new Error("Could not auto-detect Claude Code transcript path. Set sources.cc.path in config.json."); return pickPrimaryCCPath(detected); } if (key === "memory") { const detected = detectCCPath(); if (detected.length === 0) throw new Error("Could not auto-detect memory path. Set sources.memory.path in config.json."); return join(pickPrimaryCCPath(detected), "memory"); } throw new Error(`Cannot auto-detect path for source "${key}". Set it manually in config.json.`); } function formatIngestSummary(result: IngestResult): string { return `${result.filesProcessed} files, ${result.chunksIngested} ingested, ${result.chunksDeduped} deduped (${formatDedupReasonSummary(result)}), ${result.errors.length} errors`; } function maybeWarnHighCCDedupRate(result: IngestResult): void { if (result.source !== "cc") return; const dedupRate = getDedupSkipRate(result); if (dedupRate <= 0.85) return; console.log(` ⚠️ dedup 率异常: ${(dedupRate * 100).toFixed(1)}%`); } // ============================================================================ // CLI // ============================================================================ const program = new Command(); program .name("recallnest") .description("本地优先 AI 对话记忆搜索与蒸馏层") .version(PACKAGE_VERSION); async function runRetrievalView( view: "search" | "explain" | "distill", query: string, options: { limit?: string; scope?: string; sessionId?: string; allScopes?: boolean; json?: boolean; profile?: string; explain?: boolean; trace?: boolean; }, ) { const config = loadConfig(); const { retriever, profile, store } = createComponents(config, options.profile); const limit = parseLimitOption(options.limit, 5, 1, 20); // Build trace collector when --trace is set let trace: import("./retrieval-trace.js").TraceCollector | undefined; if (options.trace) { const { TraceCollector } = await import("./retrieval-trace.js"); trace = new TraceCollector(); } const results = await retriever.retrieve(buildRetrievalContext({ query, limit, scope: options.scope, sessionId: options.sessionId, allScopes: Boolean(options.allScopes), source: "cli", trace, }, { operation: `cli:${view}`, })); if (view === "search" && options.json) { console.log( JSON.stringify( { query, profile: profile.name, results: results.map((r) => ({ score: r.score, scope: r.entry.scope, text: r.entry.text, metadata: r.entry.metadata, retrievalPath: Object.keys(r.sources).filter((key) => Boolean((r.sources as any)[key])), })), }, null, 2, ), ); return; } // Print trace summary before results when --trace is set if (trace) { const mode = retriever.getConfig().mode === "vector" ? "vector" as const : "hybrid" as const; console.log(trace.summarize(query, mode)); console.log(""); // blank separator } const context = { query, profile: profile.name }; const rendered = view === "explain" ? formatExplainResults(results, context) : view === "distill" ? distillResults(results, context) : formatSearchResults(results, context); console.log(rendered); // P1-A: 显式 scope 0 命中 → 相近 scope 提示打到 stderr(不污染 stdout) if (results.length === 0 && options.scope) { try { const suggest = createScopeSuggester(async () => (await store.stats()).scopeCounts); const suggestions = (await suggest(options.scope)).filter(s => s !== options.scope); if (suggestions.length > 0) { console.error(`⚠️ scope '${options.scope}' 命中 0 条,相近 scope: [${suggestions.join(", ")}]`); } } catch { /* best-effort */ } } } function parseLimitOption(value: string | undefined, fallback: number, min = 1, max = 50): number { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function parseRequiredLimitOption(value: string | undefined, field: string, min = 1, max = 50): number { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isFinite(parsed)) { throw new Error(`${field} must be an integer`); } return Math.min(max, Math.max(min, parsed)); } function toScopeFilter(scope?: string): string[] | undefined { const trimmed = typeof scope === "string" ? scope.trim() : ""; return trimmed ? [trimmed] : undefined; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function parseExportFormat(value: string | undefined, fallback: "md" | "json" = "md"): "md" | "json" { if (!value) return fallback; if (value === "md" || value === "json") return value; throw new Error(`Invalid export format: ${value}`); } function splitCsvOption(value?: string): string[] { return String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function defaultConflictAuditExportPath(format: "md" | "json", canonicalKey?: string): string { const timestamp = new Date().toISOString().replace(/[:]/g, "-").replace(/\..+/, ""); const stem = canonicalKey ? canonicalKey .toLowerCase() .replace(/[^a-z0-9\p{Script=Han}]+/giu, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) || "conflict-audit" : "conflict-audit"; return join(getExportsDir(), `${timestamp}-${stem}.${format}`); } function parseConflictStatusOption(value?: string): "open" | "accepted-incoming" | "kept-existing" | undefined { if (!value) return undefined; const parsed = ConflictStatusSchema.safeParse(value); if (!parsed.success) { throw new Error(`Invalid conflict status: ${value}`); } return parsed.data; } function filterConflictsByAttention( records: T[], attention?: string, staleOnly?: boolean, ): T[] { if (!attention && !staleOnly) return records; return records.filter((record) => { const lifecycle = summarizeConflictLifecycle(record as any); if (staleOnly) { return lifecycle.attention === "stale" || lifecycle.attention === "escalated"; } return lifecycle.attention === attention; }); } function workflowPatternSeedsPath(file?: string): string { return file ? resolve(file) : resolve(metaDir(import.meta), "../eval/continuity/pattern-seeds.json"); } function caseMemorySeedsPath(file?: string): string { return file ? resolve(file) : resolve(metaDir(import.meta), "../eval/continuity/case-seeds.json"); } function memorySeedsPath(file?: string): string { return file ? resolve(file) : resolve(metaDir(import.meta), "../eval/continuity/memory-seeds.json"); } function parseMetadata(raw?: string): Record { try { return JSON.parse(raw || "{}") as Record; } catch { return {}; } } function extractWorkflowPatternTitle(entry: MemoryEntry): string { const metadata = parseMetadata(entry.metadata); const workflowPattern = metadata.workflowPattern; if (workflowPattern && typeof workflowPattern === "object" && typeof (workflowPattern as any).title === "string") { return String((workflowPattern as any).title).trim(); } const match = entry.text.match(/^Workflow pattern:\s*(.+)$/im); return match?.[1]?.trim() || ""; } function extractCaseMemoryTitle(entry: MemoryEntry): string { const metadata = parseMetadata(entry.metadata); const caseMemory = metadata.caseMemory; if (caseMemory && typeof caseMemory === "object" && typeof (caseMemory as any).title === "string") { return String((caseMemory as any).title).trim(); } const match = entry.text.match(/^Case:\s*(.+)$/im); return match?.[1]?.trim() || ""; } function workflowPatternIdentity(title: string, scope: string): string { return `${scope.toLowerCase()}::${title.trim().toLowerCase()}`; } function caseMemoryIdentity(title: string, scope: string): string { return `${scope.toLowerCase()}::${title.trim().toLowerCase()}`; } function normalizeSeedIdentityValue(value?: string): string { return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); } function extractStoreMemoryCanonicalKey(entry: MemoryEntry): string { const metadata = parseMetadata(entry.metadata); return typeof metadata.canonicalKey === "string" ? metadata.canonicalKey.trim() : ""; } function storeMemoryIdentity(params: { category: string; scope: string; text: string; canonicalKey?: string; }): string { const identity = normalizeSeedIdentityValue(params.canonicalKey) || normalizeSeedIdentityValue(params.text); return `${params.scope.toLowerCase()}::${params.category.toLowerCase()}::${identity}`; } function normalizeWorkflowPatternSeed(raw: unknown, defaults: { scope?: string; source?: string; }): WorkflowPatternInput { const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; return WorkflowPatternInputSchema.parse({ ...record, scope: typeof record.scope === "string" ? record.scope : defaults.scope, source: typeof record.source === "string" ? record.source : defaults.source, }); } function normalizeCaseMemorySeed(raw: unknown, defaults: { scope?: string; source?: string; }): CaseMemoryInput { const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; return CaseMemoryInputSchema.parse({ ...record, scope: typeof record.scope === "string" ? record.scope : defaults.scope, source: typeof record.source === "string" ? record.source : defaults.source, }); } function normalizeStoreMemorySeed(raw: unknown, defaults: { scope?: string; source?: string; }): StoreMemoryInput { const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; return StoreMemoryInputSchema.parse({ ...record, scope: typeof record.scope === "string" ? record.scope : defaults.scope, source: typeof record.source === "string" ? record.source : defaults.source, }); } function seedTextPreview(text: string, maxLen = 72): string { const compact = text.replace(/\s+/g, " ").trim(); return compact.length <= maxLen ? compact : `${compact.slice(0, maxLen - 3).trimEnd()}...`; } function formatLegacyScope(value: string | null): string { const kind = classifyLegacyScope(value); if (kind === "missing") return ""; if (kind === "empty") return ""; return value ?? ""; } function collectRepeatedOption(value: string, previous: string[] = []): string[] { return [...previous, value]; } function entryToRetrievalResult(entry: Awaited>): RetrievalResult { if (!entry) { throw new Error("Memory entry not found."); } return { entry, score: entry.importance || 0.7, sources: { fused: { score: entry.importance || 0.7 }, }, }; } // ─── search ────────────────────────────────────────────────────────────────── program .command("search ") .description("搜索记忆(支持 profile / explain)") .option("-n, --limit ", "返回结果数", "5") .option("-s, --scope ", "显式限定 scope") .option("--session-id ", "当前 sessionId;未传 scope 时会推断为 session:") .option("--all-scopes", "显式允许跨 scope 搜索") .option("-p, --profile ", "检索画像:default / writing / debug / fact-check", "default") .option("--explain", "显示命中原因与检索路径") .option("--trace", "显示检索管道各阶段 trace(调试用)") .option("--json", "JSON 格式输出") .action(async (query: string, options) => { await runRetrievalView(options.explain ? "explain" : "search", query, options); }); program .command("explain ") .description("解释为什么召回这些记忆") .option("-n, --limit ", "返回结果数", "5") .option("-s, --scope ", "显式限定 scope") .option("--session-id ", "当前 sessionId;未传 scope 时会推断为 session:") .option("--all-scopes", "显式允许跨 scope 搜索") .option("-p, --profile ", "检索画像:default / writing / debug / fact-check", "default") .action(async (query: string, options) => { await runRetrievalView("explain", query, options); }); program .command("distill ") .description("把命中结果蒸馏成可复用 briefing") .option("-n, --limit ", "返回结果数", "8") .option("-s, --scope ", "显式限定 scope") .option("--session-id ", "当前 sessionId;未传 scope 时会推断为 session:") .option("--all-scopes", "显式允许跨 scope 搜索") .option("-p, --profile ", "检索画像:default / writing / debug / fact-check", "writing") .action(async (query: string, options) => { await runRetrievalView("distill", query, options); }); program .command("profiles") .description("显示可用检索画像") .action(() => { const rows = [ "Profile Label What It Optimizes", "----------- -------------- -----------------------------------------------", ]; for (const profile of listRetrievalProfiles()) { rows.push( `${profile.name.padEnd(11)} ${profile.label.padEnd(14)} ${profile.description}`, ); } console.log(rows.join("\n")); }); program .command("pin ") .description("把一条命中记忆提升为 pinned asset") .option("-s, --scope ", "显式限定 scope") .option("--session-id ", "当前 sessionId;未传 scope 时会推断为 session:") .option("--all-scopes", "显式允许跨 scope 读取") .option("-p, --profile ", "检索画像:default / writing / debug / fact-check", "default") .option("-q, --query ", "记录这条记忆来自哪个查询") .option("-t, --title ", "自定义 asset 标题") .option("--summary <summary>", "自定义 asset 摘要") .action(async (memoryId: string, options) => { const config = loadConfig(); const { store, embedder } = createComponents(config, options.profile); const scopeSelection = resolveScopeSelection({ scope: options.scope, sessionId: options.sessionId, allScopes: Boolean(options.allScopes), operation: "cli:pin", }); const entry = await store.get(memoryId, scopeSelection.scopeFilter); if (!entry) { console.log(`Memory not found: ${memoryId}`); return; } const pinnedImportance = Math.max(entry.importance || 0.7, 0.95); await store.update(entry.id, { importance: pinnedImportance }, scopeSelection.scopeFilter); const asset = buildPinAsset(entryToRetrievalResult(entry), { title: options.title, summary: options.summary, query: options.query, profile: options.profile, }); const path = savePinAsset(asset); await indexPinnedAsset(store, embedder, asset); console.log([ `Pinned : ${asset.id.slice(0, 8)}`, `Memory : ${entry.id.slice(0, 8)} (${entry.scope})`, `Title : ${asset.title}`, `Path : ${path}`, ].join("\n")); }); program .command("brief <query>") .description("把一组召回结果沉淀成 structured memory brief") .option("-n, --limit <n>", "返回结果数", "8") .option("-s, --scope <scope>", "显式限定 scope") .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>") .option("--all-scopes", "显式允许跨 scope 搜索") .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "writing") .option("-t, --title <title>", "自定义 brief 标题") .action(async (query: string, options) => { const config = loadConfig(); const { retriever, profile, store, embedder } = createComponents(config, options.profile); const limit = parseLimitOption(options.limit, 8, 1, 20); const results = await retriever.retrieve(buildRetrievalContext({ query, limit, scope: options.scope, sessionId: options.sessionId, allScopes: Boolean(options.allScopes), source: "cli", }, { operation: "cli:brief", })); if (results.length === 0) { console.log("No results found."); return; } const briefSeedResults = selectBriefSeedResults(results); const summary = summarizeResults(briefSeedResults, { query, profile: profile.name }); const asset = buildBriefAsset(summary, { title: options.title }); const path = saveBriefAsset(asset); await indexAsset(store, embedder, asset); console.log([ `Brief : ${asset.id.slice(0, 8)}`, `Title : ${asset.title}`, `Hits : ${asset.hits}`, `Path : ${path}`, ].join("\n")); }); program .command("pins") .description("列出最近的 pinned assets") .option("-n, --limit <n>", "返回结果数", "10") .action((options) => { const limit = parseLimitOption(options.limit, 10, 1, 50); const rows = listPinAssets(limit); if (rows.length === 0) { console.log("No pinned assets yet."); return; } console.log("Pin ID Title Scope Date"); console.log("-------- ----- ----- ----------"); for (const row of rows) { console.log(pinSummaryLine(row)); } }); program .command("assets") .description("列出最近的 structured memory assets") .option("-n, --limit <n>", "返回结果数", "12") .action((options) => { const limit = parseLimitOption(options.limit, 12, 1, 50); const rows = listMemoryAssets(limit); if (rows.length === 0) { console.log("No assets yet."); return; } console.log("Asset ID Kind Title Scope / Sources Date"); console.log("-------- ----- ----- --------------- ----------"); for (const row of rows) { console.log(assetSummaryLine(row)); } }); program .command("clean-briefs") .description("归档旧规则生成的脏 brief,并从索引删除对应 asset scope") .option("--apply", "执行清理;默认只预览") .action(async (options) => { const dirty = listDirtyBriefAssets(); if (dirty.length === 0) { console.log("No dirty briefs found."); return; } if (!options.apply) { console.log("Dirty briefs detected:"); for (const item of dirty) { console.log(`${item.id.slice(0, 8)} ${item.title} [${item.scope}] ${item.reasons.join("; ")}`); } console.log("\nRun with --apply to archive files and delete their indexed asset scopes."); return; } const config = loadConfig(); const { store } = createComponents(config); let archived = 0; let deleted = 0; for (const item of dirty) { archiveDirtyBriefAsset(item); archived += 1; deleted += await store.bulkDelete([item.scope]); } console.log([ `Dirty briefs: ${dirty.length}`, `Archived : ${archived}`, `Index rows : ${deleted}`, ].join("\n")); }); program .command("promote-scan") .description("扫描 transcript 降级的 profile/preferences evidence,聚类高频高 importance 项晋升回 durable") .requiredOption("--scope <scope>", "要扫描的 scope,如 cc:project:recallnest") .option("--apply", "执行晋升;默认 dry-run 只预览") .option("--min-occurrences <n>", "聚类最小成员数(默认 3)", "3") .option("--min-importance <n>", "聚类最小平均 importance(默认 0.6)", "0.6") .action(async (options) => { const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const deps = buildPromoteScanDeps({ store, embedder, conflictStore }); const minOccurrences = Number(options.minOccurrences); const minImportance = Number(options.minImportance); const result = await scanMemoryPromotions(deps, options.scope, { dryRun: !options.apply, ...(Number.isFinite(minOccurrences) ? { minOccurrences } : {}), ...(Number.isFinite(minImportance) ? { minImportance } : {}), }); console.log(formatPromoteScanResult(result)); }); program .command("promote-synthesis") .description("扫描 dream 合成出的结论(evidence 层),把证据充分的那些晋升出一份 durable 副本") .requiredOption("--scope <scope>", "要扫描的 scope,如 memory") .option("--apply", "执行晋升;默认 dry-run 只预览") .option("--min-evidence <n>", "最少几条仍存活的不同证据(默认 2)", "2") .option("--min-importance <n>", "合成条目自身的最低 importance(默认 0.6)", "0.6") .action(async (options) => { const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const deps = buildPromoteScanDeps({ store, embedder, conflictStore }); const minDistinctEvidence = Number(options.minEvidence); const minImportance = Number(options.minImportance); const result = await scanSynthesisPromotions(deps, options.scope, { dryRun: !options.apply, ...(Number.isFinite(minDistinctEvidence) ? { minDistinctEvidence } : {}), ...(Number.isFinite(minImportance) ? { minImportance } : {}), }); console.log(formatSynthesisPromoteResult(result)); }); program .command("export <query>") .description("导出检索与蒸馏结果到 markdown/json") .option("-n, --limit <n>", "返回结果数", "8") .option("-s, --scope <scope>", "显式限定 scope") .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>") .option("--all-scopes", "显式允许跨 scope 搜索") .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "writing") .option("-f, --format <format>", "导出格式:md / json", "md") .action(async (query: string, options) => { const format = options.format === "json" ? "json" : "md"; const config = loadConfig(); const { retriever, profile } = createComponents(config, options.profile); const limit = parseLimitOption(options.limit, 8, 1, 20); const results = await retriever.retrieve(buildRetrievalContext({ query, limit, scope: options.scope, sessionId: options.sessionId, allScopes: Boolean(options.allScopes), source: "cli", }, { operation: "cli:export", })); const summary = distillResults(results, { query, profile: profile.name }); const artifact = writeExportArtifact({ query, profile: profile.name, results, summary, format, }); console.log([ `Exported : ${artifact.id.slice(0, 8)}`, `Format : ${artifact.format}`, `Profile : ${artifact.profile}`, `Path : ${artifact.outputPath}`, ].join("\n")); }); const conflictsCommand = program .command("conflicts") .description("查看和裁决 memory promotion conflicts"); conflictsCommand .command("list") .description("列出近期 conflict candidates") .option("-n, --limit <n>", "返回结果数", "20") .option("--status <status>", "状态过滤:open / accepted-incoming / kept-existing") .option("--attention <attention>", `生命周期过滤:${CONFLICT_ATTENTION_LEVELS.join(" / ")}`) .option("--stale", "只看 stale / escalated 的 open conflicts") .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤") .option("--group-by <mode>", "输出模式:record / cluster", "record") .option("--json", "JSON 格式输出") .action(async (options) => { const limit = parseLimitOption(options.limit, 20, 1, 50); const status = parseConflictStatusOption(options.status) || ( options.attention || options.stale ? undefined : "open" ); const attention = parseConflictAttention(options.attention); if (options.attention && !attention) { throw new Error(`Invalid attention level: ${options.attention}`); } const conflictStore = new ConflictCandidateStore(); const records = filterConflictsByAttention(await conflictStore.listRecent({ status, canonicalKey: options.canonicalKey, limit: Math.max(limit * 2, limit), }), attention, Boolean(options.stale)).slice(0, limit); const groupBy = options.groupBy === "cluster" ? "cluster" : "record"; if (options.json) { if (groupBy === "cluster") { const clusters = clusterConflicts(records); console.log(JSON.stringify({ groupBy, clusters, count: clusters.length, }, null, 2)); } else { console.log(JSON.stringify({ groupBy, conflicts: records.map((record) => ({ ...record, lifecycle: summarizeConflictLifecycle(record), advice: summarizeConflictAdvice(record), })), count: records.length, }, null, 2)); } return; } console.log(groupBy === "cluster" ? formatConflictClusters(clusterConflicts(records)) : formatConflictList(records)); }); conflictsCommand .command("show <conflictId>") .description("查看单条 conflict 的完整详情") .option("--json", "JSON 格式输出") .action(async (conflictId: string, options) => { const conflictStore = new ConflictCandidateStore(); const record = await conflictStore.getById(conflictId); if (!record) { console.log(`Conflict not found: ${conflictId}`); process.exitCode = 1; return; } if (options.json) { console.log(JSON.stringify({ ...record, lifecycle: summarizeConflictLifecycle(record), advice: summarizeConflictAdvice(record), }, null, 2)); return; } console.log(formatConflictRecord(record)); }); conflictsCommand .command("audit") .description("生成一份面向终端的 conflict audit 摘要,优先指出 stale / escalated clusters") .option("-n, --limit <n>", "扫描的 conflict 记录数", "100") .option("--top <n>", "展示的 priority cluster 数", "5") .option("--status <status>", "状态过滤:open / accepted-incoming / kept-existing / merged") .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤") .option("--export", "把 audit 报告导出到文件") .option("--format <format>", "导出格式:md / json", "md") .option("--output <path>", "导出文件路径;默认写到 data/exports") .option("--json", "JSON 格式输出") .action(async (options) => { const limit = parseLimitOption(options.limit, 100, 1, 500); const top = parseLimitOption(options.top, 5, 1, 20); const status = parseConflictStatusOption(options.status); const conflictStore = new ConflictCandidateStore(); const records = await conflictStore.listRecent({ status, canonicalKey: options.canonicalKey, limit, }); const summary = buildConflictAuditSummary(records, top); const exportRequested = Boolean(options.export || options.output); if (options.json) { const payload = { generatedAt: new Date().toISOString(), filters: { status: status || "all", canonicalKey: options.canonicalKey || "all", limit, top, }, summary, }; if (exportRequested) { const format = parseExportFormat(options.format); const outputPath = options.output ? resolve(options.output) : defaultConflictAuditExportPath(format, options.canonicalKey); const rendered = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : formatConflictAuditMarkdown(summary, { generatedAt: payload.generatedAt, limit, top, ...(status ? { status } : {}), ...(options.canonicalKey ? { canonicalKey: options.canonicalKey } : {}), }); writeFileSync(outputPath, rendered); console.log(JSON.stringify({ ...payload, export: { format, outputPath } }, null, 2)); return; } console.log(JSON.stringify(payload, null, 2)); return; } const rendered = formatConflictAudit(summary); if (!exportRequested) { console.log(rendered); return; } const format = parseExportFormat(options.format); const generatedAt = new Date().toISOString(); const outputPath = options.output ? resolve(options.output) : defaultConflictAuditExportPath(format, options.canonicalKey); const exported = format === "json" ? JSON.stringify({ generatedAt, filters: { status: status || "all", canonicalKey: options.canonicalKey || "all", limit, top, }, summary, }, null, 2) + "\n" : formatConflictAuditMarkdown(summary, { generatedAt, limit, top, ...(status ? { status } : {}), ...(options.canonicalKey ? { canonicalKey: options.canonicalKey } : {}), }); writeFileSync(outputPath, exported); console.log(`${rendered}\n\nExported: ${outputPath}`); }); conflictsCommand .command("escalate") .description("预览或应用 conflict aging / escalation policy") .option("--attention <attention>", "仅处理 stale / escalated", "stale") .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤") .option("-n, --limit <n>", "扫描的 open conflict 数", "100") .option("--top <n>", "最多处理多少条 eligible conflict", "10") .option("--apply", "真正写回 escalation metadata;默认只预览") .option("--notes <notes>", "写回时附带备注") .option("--json", "JSON 格式输出") .action(async (options) => { const attention = options.attention === "escalated" ? "escalated" : options.attention === "stale" ? "stale" : null; if (!attention) { throw new Error("Invalid escalation attention. Use stale or escalated."); } const conflictStore = new ConflictCandidateStore(); const result = await escalateConflicts({ conflictStore, }, { attention, canonicalKey: options.canonicalKey, limit: parseLimitOption(options.limit, 100, 1, 500), top: parseLimitOption(options.top, 10, 1, 20), apply: Boolean(options.apply), notes: options.notes, }); if (options.json) { console.log(JSON.stringify(result, null, 2)); return; } console.log(formatConflictEscalation(result)); }); conflictsCommand .command("resolve [conflictId]") .description("解决一条或一批 conflict candidates") .option("--keep-existing", "保留现有 durable memory") .option("--accept-incoming", "接受 incoming promoted text") .option("--merge", "合并 existing 与 incoming wording") .option("--merged-text <text>", "merge 时可选覆盖默认 merge suggestion 的文本") .option("--all", "批量处理符合过滤条件的 conflicts") .option("--status <status>", "批量过滤状态:open / accepted-incoming / kept-existing / merged", "open") .option("--attention <attention>", `批量过滤生命周期:${CONFLICT_ATTENTION_LEVELS.join(" / ")}`) .option("--stale", "批量只处理 stale / escalated 的 open conflicts") .option("-k, --canonical-key <canonicalKey>", "批量时按 canonicalKey 过滤") .option("-n, --limit <n>", "批量处理上限", "20") .option("--notes <notes>", "可选备注") .option("--json", "JSON 格式输出") .action(async (conflictId: string | undefined, options) => { const keepExisting = Boolean(options.keepExisting); const acceptIncoming = Boolean(options.acceptIncoming); const merge = Boolean(options.merge); const selectedCount = [keepExisting, acceptIncoming, merge].filter(Boolean).length; if (selectedCount !== 1) { throw new Error("Choose exactly one of --keep-existing, --accept-incoming, or --merge"); } if (options.all && conflictId) { throw new Error("Use either a conflictId or --all, not both"); } if (!options.all && !conflictId) { throw new Error("Provide a conflictId or use --all"); } if (options.mergedText && !merge) { throw new Error("--merged-text can only be used with --merge"); } if (options.all && options.mergedText) { throw new Error("--merged-text is only supported when resolving a single conflict"); } const resolution = keepExisting ? "keep_existing" : acceptIncoming ? "accept_incoming" : "merge"; const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const deps = { store, embedder, conflictStore }; if (options.all) { const limit = parseLimitOption(options.limit, 20, 1, 100); const status = parseConflictStatusOption(options.status) || "open"; const attention = parseConflictAttention(options.attention); if (options.attention && !attention) { throw new Error(`Invalid attention level: ${options.attention}`); } const records = filterConflictsByAttention(await conflictStore.listRecent({ status, canonicalKey: options.canonicalKey, limit: Math.max(limit * 2, limit), }), attention, Boolean(options.stale)).slice(0, limit); const results: Array<{ conflictId: string; status?: string; error?: string }> = []; for (const record of records) { try { const resolved = await resolveConflictCandidate(deps, { conflictId: record.conflictId, resolution, notes: options.notes, }); results.push({ conflictId: resolved.conflictId, status: resolved.status }); } catch (error) { results.push({ conflictId: record.conflictId, error: errorMessage(error) }); } } if (options.json) { console.log(JSON.stringify({ total: records.length, resolved: results.filter((item) => item.status).length, failed: results.filter((item) => item.error).length, results, }, null, 2)); return; } const lines = [ `Batch resolve: ${records.length} conflict(s)`, `Resolution : ${resolution}`, ]; if (options.canonicalKey) { lines.push(`Canonical : ${options.canonicalKey}`); } lines.push(""); for (const item of results) { lines.push( item.error ? `${item.conflictId.slice(0, 8)} ERROR ${item.error}` : `${item.conflictId.slice(0, 8)} ${item.status}`, ); } console.log(lines.join("\n")); return; } const result = await resolveConflictCandidate(deps, { conflictId, resolution, ...(options.mergedText ? { mergedText: options.mergedText } : {}), notes: options.notes, }); if (options.json) { console.log(JSON.stringify(result, null, 2)); return; } console.log(formatConflictResolution(result)); }); program .command("seed-patterns [file]") .description("批量写入 continuity / workflow pattern seeds") .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)") .option("--source <source>", "默认 source(manual / agent / api)", "agent") .option("--json", "JSON 输出") .action(async (file: string | undefined, options) => { const path = workflowPatternSeedsPath(file); const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown; if (!Array.isArray(raw)) { throw new Error(`Pattern seeds file must be a JSON array: ${path}`); } const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const existing = await store.list(undefined, "patterns", 1000, 0); const existingKeys = new Set( existing .map((entry) => { const title = extractWorkflowPatternTitle(entry); return title ? workflowPatternIdentity(title, entry.scope) : ""; }) .filter(Boolean), ); const stored: Array<{ title: string; id: string; scope: string }> = []; const skipped: Array<{ title: string; scope: string }> = []; for (const seed of raw) { const input = normalizeWorkflowPatternSeed(seed, { scope: options.scope, source: options.source, }); const scope = input.scope; const key = workflowPatternIdentity(input.title, scope); if (existingKeys.has(key)) { skipped.push({ title: input.title, scope }); continue; } const record = await persistWorkflowPattern({ store, embedder, conflictStore, auditLogger: createAuditLogger() }, input); existingKeys.add(key); stored.push({ title: record.title, id: record.id, scope: record.resolvedScope }); } if (options.json) { console.log(JSON.stringify({ path, stored, skipped }, null, 2)); return; } const lines = [ `Pattern seeds: ${path}`, `Stored : ${stored.length}`, `Skipped : ${skipped.length}`, ]; if (stored.length > 0) { lines.push(""); lines.push("Stored patterns:"); for (const item of stored) { lines.push(` ${item.id.slice(0, 8)} ${item.scope} ${item.title}`); } } if (skipped.length > 0) { lines.push(""); lines.push("Skipped existing:"); for (const item of skipped) { lines.push(` ${item.scope} ${item.title}`); } } console.log(lines.join("\n")); }); program .command("seed-cases [file]") .description("批量写入 continuity / reusable case seeds") .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)") .option("--source <source>", "默认 source(manual / agent / api)", "agent") .option("--json", "JSON 输出") .action(async (file: string | undefined, options) => { const path = caseMemorySeedsPath(file); const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown; if (!Array.isArray(raw)) { throw new Error(`Case seeds file must be a JSON array: ${path}`); } const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const existing = await store.list(undefined, "cases", 1000, 0); const existingKeys = new Set( existing .map((entry) => { const title = extractCaseMemoryTitle(entry); return title ? caseMemoryIdentity(title, entry.scope) : ""; }) .filter(Boolean), ); const stored: Array<{ title: string; id: string; scope: string }> = []; const skipped: Array<{ title: string; scope: string }> = []; for (const seed of raw) { const input = normalizeCaseMemorySeed(seed, { scope: options.scope, source: options.source, }); const scope = input.scope; const key = caseMemoryIdentity(input.title, scope); if (existingKeys.has(key)) { skipped.push({ title: input.title, scope }); continue; } const record = await persistCaseMemory({ store, embedder, conflictStore, auditLogger: createAuditLogger() }, input); existingKeys.add(key); stored.push({ title: record.title, id: record.id, scope: record.resolvedScope }); } if (options.json) { console.log(JSON.stringify({ path, stored, skipped }, null, 2)); return; } const lines = [ `Case seeds: ${path}`, `Stored : ${stored.length}`, `Skipped : ${skipped.length}`, ]; if (stored.length > 0) { lines.push(""); lines.push("Stored cases:"); for (const item of stored) { lines.push(` ${item.id.slice(0, 8)} ${item.scope} ${item.title}`); } } if (skipped.length > 0) { lines.push(""); lines.push("Skipped existing:"); for (const item of skipped) { lines.push(` ${item.scope} ${item.title}`); } } console.log(lines.join("\n")); }); program .command("seed-memories [file]") .description("批量写入 durable project / continuity memory seeds") .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)") .option("--source <source>", "默认 source(manual / agent / api)", "agent") .option("--json", "JSON 输出") .action(async (file: string | undefined, options) => { const path = memorySeedsPath(file); const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown; if (!Array.isArray(raw)) { throw new Error(`Memory seeds file must be a JSON array: ${path}`); } const config = loadConfig(); const { store, embedder } = createComponents(config); const conflictStore = new ConflictCandidateStore(); const existing = await store.list(undefined, undefined, 2000, 0); const existingKeys = new Set( existing .map((entry) => storeMemoryIdentity({ category: entry.category, scope: entry.scope, text: entry.text, canonicalKey: extractStoreMemoryCanonicalKey(entry), })) .filter(Boolean), ); const stored: Array<{ category: string; id: string; scope: string; text: string }> = []; const skipped: Array<{ category: string; scope: string; text: string }> = []; for (const seed of raw) { const input = normalizeStoreMemorySeed(seed, { scope: options.scope, source: options.source, }); const scope = input.scope; const key = storeMemoryIdentity(input); if (existingKeys.has(key)) { skipped.push({ category: input.category, scope, text: input.text }); continue; } const record = await persistMemory({ store, embedder, conflictStore, auditLogger: createAuditLogger() }, input); existingKeys.add(storeMemoryIdentity({ category: input.category, scope: record.resolvedScope, text: input.text, canonicalKey: record.canonicalKey, })); stored.push({ category: record.category, id: record.id, scope: record.resolvedScope, text: record.text, }); } if (options.json) { console.log(JSON.stringify({ path, stored, skipped }, null, 2)); return; } const lines = [ `Memory seeds: ${path}`, `Stored : ${stored.length}`, `Skipped : ${skipped.length}`, ]; if (stored.length > 0) { lines.push(""); lines.push("Stored memories:"); for (const item of stored) { lines.push(` ${item.id.slice(0, 8)} ${item.scope} [${item.category}] ${seedTextPreview(item.text)}`); } } if (skipped.length > 0) { lines.push(""); lines.push("Skipped existing:"); for (const item of skipped) { lines.push(` ${item.scope} [${item.category}] ${seedTextPreview(item.text)}`); } } console.log(lines.join("\n")); }); // ─── demo ──────────────────────────────────────────────────────────────────── program .command("demo") .description("运行示例搜索,展示 RecallNest 工作效果") .action(async () => { const demoQueries = [ "how to debug a failing bot", "telegram bridge setup", "memory search architecture", ]; console.log("\n RecallNest Demo\n"); console.log(" Running sample queries to show what RecallNest can do.\n"); const config = loadConfig(); const { store } = createComponents(config); const stats = await store.stats(); if (stats.totalCount === 0) { console.log(" Index is empty. Run `lm ingest --source all` first.\n"); return; } console.log(` Index: ${stats.totalCount} entries across ${Object.keys(stats.scopeCounts).length} scopes\n`); const { retriever, profile } = createComponents(config); for (const query of demoQueries) { console.log(` Query: "${query}"`); const results = await retriever.retrieve({ query, limit: 3 }); if (results.length === 0) { console.log(" (no results)\n"); continue; } for (const r of results) { const preview = r.entry.text.slice(0, 80).replace(/\n/g, " "); console.log(` [${r.score.toFixed(2)}] ${r.entry.scope} — ${preview}...`); } console.log(); } console.log(" Try your own: lm search \"your question here\"\n"); }); // ─── ingest ────────────────────────────────────────────────────────────────── program .command("ingest") .description("导入对话记录到索引") .option("-s, --source <source>", "数据源: cc / codex / gemini / kimi / memory / desktop / minis / all", "all") .option("-l, --limit <n>", "限制处理文件数(调试用)") .option("-v, --verbose", "详细输出") .option("--no-dedup", "跳过向量去重") .option("--no-llm", "禁用 LLM 提取(原始对话将跳过不存入,排入待处理队列)") .option("--recent [hours]", "快速模式:只处理最近 N 小时修改的文件(默认 2 小时),跳过 ingested-files 检查") .option("--obsidian <vault-path>", "导入 Obsidian vault(自动检测 .obsidian/ 目录)") .option("--connector <file-path>", "导入 connector-v1 格式 JSON 文件") .action(async (options) => { const config = loadConfig(); const { store, embedder, llm } = createComponents(config); const source = options.source || "all"; const limit = options.limit ? parseRequiredLimitOption(options.limit, "--limit", 1, Number.MAX_SAFE_INTEGER) : undefined; const verbose = options.verbose || false; const noDedup = options.dedup === false; // --no-dedup // --recent: quick mode — only process files modified within N hours, skip ingested-files check const recentHours: number | undefined = options.recent !== undefined ? (options.recent === true ? 2 : Number(options.recent)) : undefined; if (recentHours !== undefined && (isNaN(recentHours) || recentHours <= 0)) { console.error("❌ --recent 参数必须是正数(小时数)"); process.exitCode = 1; return; } const results: any[] = []; // Pre-flight: validate embedding API before processing any files console.log("\n🔑 验证 Embedding API..."); const testResult = await embedder.test(); if (!testResult.success) { console.error(`\n❌ Embedding API 验证失败: ${testResult.error}`); console.error(" → 检查 .env 中的 JINA_API_KEY 是否正确"); console.error(" → 获取免费 key: https://jina.ai/embeddings/"); console.error(" → 运行 lm doctor 查看完整诊断\n"); process.exitCode = 1; return; } console.log(` ✅ ${config.embedding.model} (${testResult.dimensions}d)`); // LLM status — respect --no-llm flag // commander 把 --no-llm 解析成 options.llm=false(不产生 options.noLlm),对齐 --no-dedup 的取反键写法 const noLlm = options.llm === false; const effectiveLlm = noLlm ? null : llm; if (noLlm) { console.log(" ⚠️ LLM: 已通过 --no-llm 禁用,原始对话将跳过不存入"); } else if (llm) { const llmTest = await llm.test(); console.log(llmTest.success ? ` ✅ LLM: ${config.llm?.model} (L0 摘要 + 语义去重)` : ` ⚠️ LLM: ${config.llm?.model} 连接失败,原始对话将跳过不存入`); } else { console.log(" ⚠️ LLM: 未配置,原始对话将跳过不存入(配置 config.json → llm 以启用)"); } // Drain pending queue if LLM is available if (effectiveLlm) { const { drainPendingQueue } = await import("./ingest.js"); const drained = await drainPendingQueue(store, embedder, effectiveLlm); if (drained.processed > 0) { console.log(` ♻️ Pending queue: ${drained.processed} processed, ${drained.errors} errors`); } } console.log(` ${noDedup ? "⚠️ 去重已禁用 (--no-dedup)" : "✅ 两阶段去重: vector + LLM 语义判断"}`); if (recentHours !== undefined) { console.log(` ⚡ 快速模式: 只处理最近 ${recentHours} 小时修改的文件,跳过 ingested-files 检查`); } console.log(); console.log(`🔄 开始导入记忆 (source: ${source})...\n`); const ingestOpts = { limit, verbose, noDedup, llm: effectiveLlm, recentHours }; // CC Transcripts // CC 按 cwd 编码 project 子目录,所有有 jsonl 的子目录都要 ingest(不只是 jsonl 最多的那个) if (source === "all" || source === "cc") { console.log("📝 导入 Claude Code 对话..."); const ccSource = config.sources.cc; if (ccSource) { const ccPaths = ccSource.path === "auto" ? detectCCPath() : [expandHome(ccSource.path)]; if (ccPaths.length === 0) { throw new Error("Could not auto-detect Claude Code transcript path. Set sources.cc.path in config.json."); } for (const ccPath of ccPaths) { const r = await ingestCCTranscripts(store, embedder, ccPath, ingestOpts); results.push(r); console.log(` ✅ CC [${basename(ccPath)}]: ${formatIngestSummary(r)}`); maybeWarnHighCCDedupRate(r); } } } // Codex Sessions if (source === "all" || source === "codex") { console.log("🤖 导入 Codex 对话..."); const r = await ingestCodexSessions(store, embedder, ingestOpts); results.push(r); console.log(` ✅ Codex: ${formatIngestSummary(r)}`); } // Kimi Code Sessions (wire.jsonl 事件流,~/.kimi-code/sessions/) if (source === "all" || source === "kimi") { console.log("🌙 导入 Kimi Code 对话..."); const r = await ingestKimiSessions(store, embedder, ingestOpts); results.push(r); console.log(` ✅ Kimi: ${formatIngestSummary(r)}`); } // Gemini Sessions (JSON format under ~/.gemini/tmp/*/chats/) if (source === "all" || source === "gemini") { console.log("💎 导入 Gemini 对话..."); const r = await ingestGeminiSessions(store, embedder, { limit, verbose, noDedup, llm: effectiveLlm, recentHours, }); results.push(r); console.log(` ✅ Gemini: ${formatIngestSummary(r)}`); } // Claude Desktop / claude.ai web conversations (CC transcript format) if (source === "all" || source === "desktop") { const desktopSource = config.sources.desktop; if (desktopSource) { const desktopPath = resolve(metaDir(import.meta), "..", desktopSource.path); if (existsSync(desktopPath)) { console.log("🖥️ 导入 Claude Desktop 对话..."); const r = await ingestCCTranscripts(store, embedder, desktopPath, ingestOpts); results.push(r); console.log(` ✅ Desktop: ${formatIngestSummary(r)}`); maybeWarnHighCCDedupRate(r); } else if (source === "desktop") { console.log("⚠️ Desktop 目录不存在,请先运行: bun scripts/pull-claude-desktop.ts"); } } } // Minis (iPhone) —— 手机 agent 自己把对话写进这个目录,ingest 顺带扫走。 // 为什么是独立源而不是复用 desktop:scope 前缀决定了 memory-boundaries 怎么降权, // 混在 cc: 里的话来源在库里就分辨不出来(那正是 2026-08-12 起挂着的缺口)。 if (source === "all" || source === "minis") { const minisSource = config.sources.minis; if (minisSource) { const minisPath = resolveSourcePath(minisSource.path, "minis"); if (existsSync(minisPath)) { console.log("📱 导入 Minis 对话..."); const r = await ingestCCTranscripts(store, embedder, minisPath, { ...ingestOpts, scopePrefix: "minis", }); results.push(r); console.log(` ✅ Minis: ${formatIngestSummary(r)}`); } else if (source === "minis") { console.log(`⚠️ Minis 目录不存在: ${minisPath}`); } } } // Memory markdown files if (source === "all" || source === "memory") { console.log("📚 导入记忆文件..."); const memSource = config.sources.memory; if (memSource) { const memPath = resolveSourcePath(memSource.path, "memory"); const r = await ingestMarkdownFiles(store, embedder, memPath, "memory", { verbose, noDedup, llm: effectiveLlm, recentHours, }); results.push(r); console.log(` ✅ Memory: ${formatIngestSummary(r)}`); } } // Obsidian vault (--obsidian <path>) if (options.obsidian) { console.log("🗃️ 导入 Obsidian vault..."); const r = await ingestObsidianVault(store, embedder, options.obsidian, { verbose, noDedup, llm: effectiveLlm, }); results.push(r); console.log(` ✅ Obsidian: ${formatIngestSummary(r)}`); } // Connector-v1 file (--connector <path>) if (options.connector) { console.log("🔌 导入 connector-v1 文件..."); const { readFileSync: readFs } = await import("node:fs"); const connectorContent = readFs(options.connector, "utf-8"); const r = await ingestConnectorFile(store, embedder, connectorContent, { verbose, noDedup, llm: effectiveLlm, }); results.push(r); console.log(` ✅ Connector: ${formatIngestSummary(r)}`); } // Summary console.log("\n📊 导入汇总:"); let totalChunks = 0; let totalDeduped = 0; let totalErrors = 0; const totalDedupReasons = { hard: 0, exact: 0, "llm-skip": 0, "llm-merge": 0, unique: 0, } as IngestResult["dedupReasonCounts"]; for (const r of results) { totalChunks += r.chunksIngested; totalDeduped += r.chunksDeduped; totalErrors += r.errors.length; for (const reason of Object.keys(totalDedupReasons) as Array<keyof typeof totalDedupReasons>) { totalDedupReasons[reason] += r.dedupReasonCounts[reason]; } if (r.errors.length > 0 && verbose) { console.log(` ⚠️ ${r.source} errors:`); for (const e of r.errors.slice(0, 5)) { console.log(` ${e}`); } } } console.log( ` 总计: ${totalChunks} chunks 已索引, ${totalDeduped} chunks 去重 (hard:${totalDedupReasons.hard}, exact:${totalDedupReasons.exact}, llm-skip:${totalDedupReasons["llm-skip"]}, llm-merge:${totalDedupReasons["llm-merge"]}), ${totalErrors} errors`, ); console.log(); }); // ─── dream ─────────────────────────────────────────────────────────────────── // P3 调度化入口:launchd 经 scripts/dream-consolidation.sh 调用本命令, // 输出 [[DREAM_STATUS]] ok|skip|blocked 标记供脚本判定(同 weekly-distill 的 // [[DISTILL_STATUS]] 模式)。dream 是 auto-gc / usageStatus 快照 / consolidation // 的唯一生产触发点——没有它们整条维护链就是死代码(2026-06-11 临床重审实锤)。 program .command("dream") .description("运行 dream 离线整固管线 (Orient/Gather/Consolidate/Prune)") .option("--scope <scope>", "目标 scope(默认前缀家族匹配;加 --exact-scope 变精确)", "cc") .option("--exact-scope", "把 --scope 当成一个具体 scope 名精确匹配,不做前缀家族展开(调度入口必带)") .option("--auto", "扫描 activity-counter 中写计数达标的所有 scope,逐个跑 dream(单 scope 失败不阻断其他)") .option("--force", "跳过 min-writes 门槛强制运行") .action(async (options: { scope: string; auto?: boolean; force?: boolean; exactScope?: boolean }) => { const config = loadConfig(); const { store, embedder, llm } = createComponents(config); // Same activity-stats file the store writes per-scope counts to (beside the LanceDB dir). const activityStatsPath = join(dirname(store.dbPath), "activity-stats.json"); // KG evidence for triple-overlap merging — same gate as the MCP server const kgStore = isKGModeEnabled() ? new KGStore({ dbPath: store.dbPath }) : null; if (options.auto) { const eligible = listScopesAboveThreshold(DEFAULT_DREAM_CONFIG.minWritesForDream, { statsPath: activityStatsPath }); // 巨型 scope 交给专用 schedule,不占日常轮次的 wall-clock 预算(理由见 // DreamConfig.autoExcludeScopes:07-30 那轮 6h 只跑了 15/507,而 memory 独占 79% 的条数)。 const { run: nonMemoryScopes, deferred } = partitionAutoDreamScopes(eligible, DEFAULT_DREAM_CONFIG.autoExcludeScopes); if (deferred.length > 0) { // 不静默:被挪走的 scope 必须在日志里看得见,否则"今天怎么没跑 memory"只能靠翻代码回答。 // 前缀刻意用 `[dream]` 而不是 `dream --auto:`——后者是 dream-checkup.sh 抓「达标 scope 数」 // 的锚点,多一行同前缀的会混进它的取数。 console.log(`[dream] ${deferred.length} 个 scope 交给专用 schedule,本轮不跑 → ${deferred.join(", ")}`); } // 2026-08-16 策略排除(互审 C2/K5):与上面的 deferred **语义不同,别合并**—— // deferred 是"别处会跑",这里是"没有任何 schedule 会跑"。日志措辞必须分开, // 否则将来查"pivot 怎么没巩固"会被误导去找那个并不存在的专用 job。 // 同时清写计数:runDream 对它返回 skipped 不会 resetWriteCount,不清则计数无限 // 累积、每天都进达标队列白跑一次(这正是 07-02 那个"处理完不出队"bug 的形状)。 const neverDream = DEFAULT_DREAM_CONFIG.neverDreamScopes; const policyExcluded = nonMemoryScopes.filter(s => neverDream.includes(s)); const afterPolicy = nonMemoryScopes.filter(s => !neverDream.includes(s)); if (policyExcluded.length > 0) { const prunedPolicy = await pruneWriteCounts(s => neverDream.includes(s), { statsPath: activityStatsPath }); console.log( `[dream] ${policyExcluded.length} 个 scope 按策略不参与巩固(neverDreamScopes,无专用 schedule)` + ` → ${policyExcluded.join(", ")};已清写计数 ${prunedPolicy.length} 个`, ); } // 2026-08-14 transcript scope 整体出队(dream 写爆库根治的准入侧): // transcript 是原文层(shared-behaviors §5.3——原文归 Deja/ingest 管,提炼归 // pivot 池),历史会话被 ingest 批量灌入就把写计数打过门槛,2026-08 实测达标 // 队列 95% 是 codex/cc 会话 scope、dream 派生产物 96.6% 落在不参与日常检索的 // transcript scope 上——巩固算力几乎全部空转,还把 _versions 写到 7.7GB。 // 出队同时清扫计数(含未达标条目——历史会话只增不减,不清则 stats 文件无限膨胀; // ingest 白天会再写回、次日 04:00 再清,稳态保持小)。判定用 memory-boundaries // 的 isTranscriptScope(接新端时那边本来就要求同步前缀清单)。 const scopes = afterPolicy.filter(s => !isTranscriptScope(s)); const transcriptEligible = afterPolicy.length - scopes.length; const prunedTranscripts = await pruneWriteCounts(isTranscriptScope, { statsPath: activityStatsPath }); if (prunedTranscripts.length > 0 || transcriptEligible > 0) { console.log( `[dream] transcript 原文层不参与巩固(§5.3):清扫 ${prunedTranscripts.length} 个 scope 的写计数` + `(其中达标 ${transcriptEligible} 个)→ 样例: ${prunedTranscripts.slice(0, 5).join(", ")}`, ); } // 2026-08-14 全库 GC 兜底(互审 C5-P0):dream 是 auto-gc 的唯一生产触发点,而 // maybeRunGc 藏在 runDreamInner 里 —— transcript 出队后 durable 队列可为空,若在 // 进循环前就 exit,全库 GC 检查会静默消失、归档管线停转。这里无条件补调一次: // GC 自带 24h stamp + 跨进程锁,循环内已触发过则本次 too_soon 跳过,幂等零成本。 // 失败只记日志不并入 blocked:GC 是 best-effort 维护,明天还有机会。 const runGcBackstop = async (): Promise<void> => { try { const gc = await maybeRunGc(store, DEFAULT_DREAM_CONFIG.gc); console.log( `[dream] 全库 GC 兜底: ${gc.triggered ? `archived ${gc.archivedCount}, dissolved version groups ${gc.dissolvedVersionGroups}` : gc.dissolvedVersionGroups > 0 ? `dissolved version groups ${gc.dissolvedVersionGroups}; archive skipped — ${gc.reason}` : `skipped — ${gc.reason}`}`, ); } catch (err) { console.error(`[dream] 全库 GC 兜底失败(不阻断本轮判定): ${err instanceof Error ? err.message : String(err)}`); } }; if (scopes.length === 0) { // 三种空分开说:真没人达标 / 达标的全被挪走或全是 transcript——混成一句会让排障跑偏。 console.log( eligible.length === 0 ? "dream --auto: 无达标 scope(写计数均低于门槛)" : "dream --auto: 达标 scope 均为 transcript(已清扫)、已交专用 schedule 或按策略排除,本轮无事可做", ); await runGcBackstop(); console.log("[[DREAM_STATUS]] skip"); process.exit(0); } console.log(`dream --auto: ${scopes.length} 个达标 scope → ${scopes.join(", ")}`); // Wall-clock 预算(2026-07-29 补):防单轮跑成跨天。07-24 那轮跑了 4 天 15 小时, // 把 07-25~27 三天的调度全堵死(上一次没结束,launchd 起不了新的)。 // 天花板见 DreamConfig.autoRunBudgetMs 的注释——截断会让靠后 scope 系统性饿死, // 真解是 session scope TTL,不是把预算调大。 const budgetMs = dreamBudgetMs(DEFAULT_DREAM_CONFIG.autoRunBudgetMs); const deadline = Date.now() + budgetMs; let fatalFailures = 0; let transientFailures = 0; let processed = 0; // 2026-08-12 产出计量:`[[DREAM_STATUS]] ok` 只证明「没崩」,不证明「产出了东西」。 // 2026-07-23 查出的语义阈值卡在真实 p99 之上,就是这样静默跑了 2716 次零 insight // 而无人察觉 —— 当时日志里每一轮都是 ok。 const tally = { produced: 0, noop: 0, partial: 0, skipped: 0 }; let effectsTotal = 0; const degradedScopes: string[] = []; for (const scope of scopes) { if (Date.now() > deadline) { console.warn( `[dream] wall-clock 预算 ${Math.round(budgetMs / 60_000)} 分钟耗尽:已处理 ` + `${processed}/${scopes.length},剩余 ${scopes.length - processed} 个 scope 本轮跳过`, ); break; } processed++; try { // scopeMatch: "exact" —— 这些 scope 名直接来自 activity-stats 的键,是具体 // scope 不是 family selector。无冒号的键(实测 11 个,含 `cc` / `global` / // `memory`)走前缀会把整个家族卷进来:`cc` 一旦达标就以 `LIKE 'cc%'` 把 08-14 // 刚出队的 transcript 原文层整批捞回(2026-08-16 互审 K4)。 const result = await runDream({ store, llm, embedder, scope, force: Boolean(options.force), activityStatsPath, kgStore, scopeMatch: "exact" }); tally[result.output.kind]++; effectsTotal += result.output.effectsWritten; if (result.output.degraded) degradedScopes.push(`${scope}=${result.output.degraded}`); console.log(`[scope=${scope}] ${formatDreamResult(result)}`); } catch (err) { // 单 scope 失败不阻断其他 scope。 // 打完整 stack 而不只是 message:像「Array.from requires an array-like object」 // 这种原生错误,只有 message 时根本定位不到是哪一行抛的——2026-07-18/19 连着两天 // 因此 blocked,回头查日志除了这一句什么线索都没有,只能靠猜。 // 分类基于 message(不是 stack):stack 里的文件路径不该参与判定。 const message = err instanceof Error ? err.message : String(err); if (classifyDreamFailure(message) === "transient") transientFailures++; else fatalFailures++; console.error(`[scope=${scope}] dream failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); } } // 全库 GC 兜底:循环内的 per-scope GC 若已触发过,这里 too_soon 幂等跳过; // 若全部 scope 都 skip/失败没走到 prune 阶段,这里补上当天唯一一次 GC 检查。 await runGcBackstop(); // 旧语义是「任一 scope 抛异常即 blocked」,在 475 个 scope 的规模下等于「有一个撞上 // store-write 锁就整轮红」,而整轮红会让 shell 脚本重试 → 重跑全量。改为:代码/数据 // 缺陷一票否决,环境性失败要成规模才算整轮失败。比例按已处理数算,不按总数。 // 结构性降级一票否决:它不是「今天数据不成簇」(那是合法的 noop),而是某条路径 // 整段断了 —— 向量管线空、或压根没配 LLM。判据只认确定性零值,不含任何阈值, // 所以不会因为数据分布变化而误伤(详见 assertDreamSweepHealth 的注释)。 const structurallyBroken = degradedScopes.length > 0; if (structurallyBroken) { console.error(`[dream] ⚠ 结构性降级 ${degradedScopes.length} 个 scope:${degradedScopes.join(", ")}`); } const blocked = shouldBlockDreamRun({ totalScopes: processed, fatalFailures, transientFailures }) || structurallyBroken; if (fatalFailures > 0 || transientFailures > 0) { console.log(`[dream] 失败统计:fatal=${fatalFailures} transient=${transientFailures}(已处理 ${processed})`); } // 这一行是 dream-consolidation.sh 存在性闸的锚点:STATUS=ok 却没有它 = 判失败。 // 它防的是「断言本身被某次重构悄悄删掉」—— 一个不需要调参的自检。 console.log(formatDreamMetrics({ tally, degraded: degradedScopes.length, effects: effectsTotal, processed, total: scopes.length, })); console.log(blocked ? "[[DREAM_STATUS]] blocked" : "[[DREAM_STATUS]] ok"); process.exit(blocked ? 1 : 0); } try { const result = await runDream({ store, llm, embedder, scope: options.scope, force: Boolean(options.force), activityStatsPath, kgStore, // 手动调用默认保留 family(`--scope cc` 扫 `cc:*` 是既有用法); // 调度入口(weekly job)必须显式带 --exact-scope。 scopeMatch: options.exactScope ? "exact" : "family", }); console.log(formatDreamResult(result)); // 单 scope 也必须打产出计量:dream-consolidation.sh 的存在性闸对 STATUS=ok // 一视同仁地要求这行,而本分支此前从不打 —— 08-12 那道闸上线后,周日的 // memory 轮次即使跑成功也会被判"产出断言缺失"。tally 在这里只有一格是 1。 const tally = { produced: 0, noop: 0, partial: 0, skipped: 0 }; tally[result.output.kind] = 1; console.log(formatDreamMetrics({ tally, degraded: result.output.degraded ? 1 : 0, effects: result.output.effectsWritten, processed: 1, total: 1, })); console.log(result.ran ? "[[DREAM_STATUS]] ok" : "[[DREAM_STATUS]] skip"); process.exit(0); } catch (err) { // 同 --auto 分支:带 stack,否则原生错误无从定位。 console.error(`dream failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); console.log("[[DREAM_STATUS]] blocked"); process.exit(1); } }); // ─── stats ─────────────────────────────────────────────────────────────────── program .command("stats") .description("显示索引统计") .action(async () => { const config = loadConfig(); const { store } = createComponents(config); const stats = await store.stats(); console.log("\n📊 记忆索引统计:\n"); console.log(` 总条目: ${stats.totalCount}`); console.log(); console.log(" 按来源:"); for (const [scope, count] of Object.entries(stats.scopeCounts).sort( (a, b) => b[1] - a[1], )) { console.log(` ${scope}: ${count}`); } console.log(); console.log(" 按类别:"); for (const [cat, count] of Object.entries(stats.categoryCounts)) { console.log(` ${cat}: ${count}`); } console.log(); }); program .command("scope-audit") .description("扫描 legacy scope 异常行(missing / empty / global)") .option("-n, --limit <n>", "最多显示多少条示例", "20") .option("--json", "JSON 格式输出") .action(async (options) => { const config = loadConfig(); const store = createStoreOnly(config); const limit = parseLimitOption(options.limit, 20, 1, 200); const audit = await store.auditLegacyScopes(limit); if (options.json) { console.log(JSON.stringify(audit, null, 2)); return; } if (audit.totalCount === 0) { console.log("No legacy scope anomalies found."); return; } console.log("Legacy scope audit\n"); console.log(` Total anomalies: ${audit.totalCount}`); console.log(` Missing scope : ${audit.counts.missing}`); console.log(` Empty scope : ${audit.counts.empty}`); console.log(` Global scope : ${audit.counts.global}`); if (audit.samples.length === 0) { return; } console.log("\n Sample rows:"); console.log(" ID Kind Scope Category Date Text"); console.log(" -------- ------- ----------- ------------ ---------- ----"); for (const sample of audit.samples) { const date = new Date(sample.timestamp).toISOString().slice(0, 10); console.log( ` ${sample.id.slice(0, 8)} ${sample.kind.padEnd(7)} ${formatLegacyScope(sample.scope).padEnd(11)} ${sample.category.padEnd(12)} ${date} ${seedTextPreview(sample.text, 56)}` ); } console.log("\n Use this report to migrate or delete historical unscoped rows before removing compatibility shims."); }); program .command("scope-inventory") .description("跨 memories / pins / checkpoints / workflow observations 统一盘点 legacy scope 异常") .option("-n, --limit <n>", "每层最多显示多少条示例", "20") .option("--json", "JSON 格式输出") .action(async (options) => { const config = loadConfig(); const store = createStoreOnly(config); const limit = parseLimitOption(options.limit, 20, 1, 200); const report = await collectScopeInventory({ store, sampleLimit: limit, }); if (options.json) { console.log(JSON.stringify(report, null, 2)); return; } console.log(formatScopeInventoryReport(report)); }); // ─── reset ─────────────────────────────────────────────────────────────────── program .command("reset") .description("清空索引(危险!)") .option("--yes", "跳过确认") .action(async (options) => { if (!options.yes) { console.log("⚠️ 这会清空所有已索引的记忆。确认请加 --yes"); return; } const config = loadConfig(); const dbPath = resolveDbPath(config); if (existsSync(dbPath)) { const { rmSync } = await import("node:fs"); rmSync(dbPath, { recursive: true }); console.log("✅ 索引已清空。"); } else { console.log("索引目录不存在,无需清空。"); } }); // ─── export ────────────────────────────────────────────────────────────────── program .command("export-memories") .description("导出最近记忆为 markdown(供 digital-clone 等下游消费)") .option("-d, --days <n>", "导出最近 N 天的记忆", "7") .option("-n, --limit <n>", "最多导出条数", "200") .option("-o, --output <path>", "输出文件路径(默认 stdout)") .option("-s, --scope <scope>", "限定来源") .option("--json", "输出 JSONL 格式") .action(async (options) => { const config = loadConfig(); const { store } = createComponents(config); const scopeFilter = toScopeFilter(options.scope); const limit = parseLimitOption(options.limit, 200, 1, 1000); const days = parseLimitOption(options.days, 7, 1, 365); const cutoff = Date.now() - days * 86_400_000; // List all entries, filter by time const entries = await store.list(scopeFilter, undefined, limit * 2, 0); const recent = entries .filter(e => e.timestamp >= cutoff) .slice(0, limit); if (recent.length === 0) { console.log(`最近 ${days} 天没有新记忆。`); return; } let output: string; if (options.json) { output = recent.map(e => JSON.stringify({ id: e.id, text: e.text, category: e.category, scope: e.scope, importance: e.importance, timestamp: e.timestamp, })).join("\n") + "\n"; } else { // Markdown format: grouped by scope, sorted by time const grouped: Record<string, typeof recent> = {}; for (const e of recent) { const key = e.scope || "<missing>"; if (!grouped[key]) grouped[key] = []; grouped[key].push(e); } const lines: string[] = [ `# RecallNest 记忆导出`, ``, `> 导出时间: ${new Date().toISOString().slice(0, 10)}`, `> 范围: 最近 ${days} 天, ${recent.length} 条`, ``, ]; for (const [scope, entries] of Object.entries(grouped)) { lines.push(`## ${scope}`); lines.push(``); for (const e of entries) { const date = new Date(e.timestamp).toISOString().slice(0, 10); const cat = e.category || "other"; lines.push(`### [${cat}] ${date}`); lines.push(``); lines.push(e.text); lines.push(``); lines.push(`---`); lines.push(``); } } output = lines.join("\n"); } if (options.output) { const { writeFileSync } = require("node:fs"); const { mkdirSync } = require("node:fs"); const { dirname } = require("node:path"); mkdirSync(dirname(options.output), { recursive: true }); writeFileSync(options.output, output, "utf-8"); console.log(`✅ 导出 ${recent.length} 条记忆 → ${options.output}`); } else { console.log(output); } }); // ─── doctor ────────────────────────────────────────────────────────────────── program .command("memory-utility") .description("价值回溯:查一条 memory 参与过的任务成功率与 utility(不传 id 时按 scope 批量)") .argument("[memoryId]", "memory id(完整 UUID 或前缀)") .option("--scope <scope>", "批量模式:扫这个 scope 下的条目") .option("--category <category>", "批量模式:只看这个 category") .option("--limit <n>", "批量模式扫描上限", "500") .option("--min-samples <n>", "批量模式只列出有效样本量 ≥ 此值的条目", "0") .option("--links <n>", "展示最近 N 条参与记录", "5") .option("--apply", "把算出的 utility 写回 metadata(默认只读,不写)") .option("--observations-dir <dir>", "从指定目录读 workflow observations(默认生产目录)") .option("--json", "输出 JSON") .action(async (memoryId, options) => { const { store } = createComponents(loadConfig()); const observationStore = options.observationsDir ? new WorkflowObservationStore(expandHome(options.observationsDir)) : new WorkflowObservationStore(); // 全量拉 observation:join 要看的是历史全集,不是最近 200 条。 const observations = await observationStore.listRecent({ limit: 100_000 }); const showLinks = Number(options.links) || 0; if (memoryId) { const entry = await store.getById(memoryId); if (!entry) { console.error(`未找到 memory: ${memoryId}`); process.exitCode = 1; return; } const summary = computeMemoryUtility(entry, observations); if (options.apply) { await store.patchMetadata(entry.id, (meta) => applyUtilityToMeta(meta, summary)); } if (options.json) { console.log(JSON.stringify({ ...summary, applied: Boolean(options.apply) }, null, 2)); return; } console.log(formatMemoryUtility(summary, { showLinks })); console.log(` observations 池: ${observations.length} 条`); if (options.apply) console.log(" 已写回 metadata.utility"); return; } if (!options.scope) { console.error("需要 <memoryId> 或 --scope"); process.exitCode = 1; return; } const limit = Number(options.limit) || 500; const minSamples = Number(options.minSamples) || 0; const entries = await store.listPage({ scopeFilter: [options.scope], category: options.category, limit, offset: 0, }); const summaries = entries .map((entry) => computeMemoryUtility(entry, observations)) .filter((s) => s.effectiveSamples >= minSamples && s.total > 0) .sort((a, b) => (b.utility ?? 0) - (a.utility ?? 0)); if (options.apply) { for (const summary of summaries) { await store.patchMetadata(summary.memoryId, (meta) => applyUtilityToMeta(meta, summary)); } } if (options.json) { console.log(JSON.stringify({ scanned: entries.length, matched: summaries.length, summaries }, null, 2)); return; } console.log(`scope=${options.scope} 扫描 ${entries.length} 条,其中 ${summaries.length} 条有参与记录`); console.log(`observations 池: ${observations.length} 条`); for (const summary of summaries.slice(0, 20)) { const utility = summary.utility === null ? " null" : summary.utility.toFixed(4).padStart(7); console.log( `${utility} samples=${summary.effectiveSamples.toFixed(2).padStart(6)}` + ` exact=${String(summary.exactCount).padStart(3)} session=${String(summary.sessionCount).padStart(3)}` + ` ${summary.memoryId.slice(0, 8)}`, ); } if (options.apply) console.log(`已写回 ${summaries.length} 条的 metadata.utility`); }); program .command("workflow-observe") .description("记录一条 workflow primitive observation,作为自进化观测层的 append-only 输入") .argument("<workflowId>", "workflow primitive id,如 resume_context / checkpoint_session") .argument("<summary>", "本次 observation 的简短说明") .option("--outcome <outcome>", "success | failure | corrected | missed", "success") .option("--scope <scope>", "可选 scope,如 project:recallnest") .option("--source <source>", "来源标签", "manual") .option("--signal <signal>", "失败/纠正信号标签") .option("--task <task>", "相关任务") .option("--tags <tags>", "逗号分隔 tags") .option("--tools <tools>", "逗号分隔 tools") .option("--idempotency-key <key>", "同一请求重试时用于替换旧 observation 的稳定键") .option("--reader-id <id>", "P0 join key:读者身份(默认本进程)。CLI 每次是新进程,跨会话关联要显式传") .option("--recalled-ids <ids>", "P0 join key:逗号分隔的 memory id,本次任务实际用到的记忆") .option("--json", "输出 JSON") .action(async (workflowId, summary, options) => { const store = new WorkflowObservationStore(); // CLI 是一次性进程,检索账本必然是空的(这次进程没检索过),所以不做自动补, // 只认显式传入——自动补在这里只会写出一个恒空的字段,看着像记了其实没记。 const recalledIds = splitCsvOption(options.recalledIds); const record = buildWorkflowObservationRecord({ workflowId, summary, outcome: WorkflowObservationOutcomeSchema.parse(options.outcome), scope: options.scope, source: options.source, signal: options.signal, task: options.task, tags: splitCsvOption(options.tags), tools: splitCsvOption(options.tools), idempotencyKey: options.idempotencyKey, readerId: options.readerId, ...(recalledIds.length > 0 ? { recalledIds } : {}), }); const stored = await store.save(record); if (options.json) { console.log(JSON.stringify(stored, null, 2)); return; } console.log(formatWorkflowObservationSaved(stored)); }); program .command("workflow-health") .description("查看 workflow primitive 的健康度;不带 workflowId 时输出全局 dashboard") .argument("[workflowId]", "可选 workflow primitive id") .option("--scope <scope>", "可选 scope") .option("--limit <n>", "dashboard 返回数量上限") .option("--json", "输出 JSON") .action(async (workflowId, options) => { const store = new WorkflowObservationStore(); if (workflowId) { const report = await inspectWorkflowHealth(store, { workflowId, scope: options.scope, }); if (options.json) { console.log(JSON.stringify(report, null, 2)); return; } console.log(formatWorkflowHealthReport(report)); return; } const dashboard = await inspectWorkflowDashboard(store, { scope: options.scope, limit: parseLimitOption(options.limit, 10, 1, 30), }); if (options.json) { console.log(JSON.stringify(dashboard, null, 2)); return; } console.log(formatWorkflowHealthDashboard(dashboard, options.scope)); }); program .command("workflow-evidence") .description("为某个 workflow primitive 生成 evidence pack,方便后续做规则/测试收口") .argument("<workflowId>", "workflow primitive id") .option("--scope <scope>", "可选 scope") .option("--limit <n>", "最近 issue observation 数量上限") .option("--json", "输出 JSON") .action(async (workflowId, options) => { const store = new WorkflowObservationStore(); const pack = await buildWorkflowEvidence(store, { workflowId, scope: options.scope, limit: parseLimitOption(options.limit, 5, 1, 20), }); if (options.json) { console.log(JSON.stringify(pack, null, 2)); return; } console.log(formatWorkflowEvidencePack(pack)); }); program .command("workflow-scope-review") .description("审查指定 scope(默认 global)的 workflow observations,并按 cue 预览/回填目标 scope") .option("--current-scope <scope>", "要审查的当前 scope", "global") .option("--source <source>", "可选 source 过滤,如 managed") .option("--workflow-id <workflowId>", "可选 workflowId 过滤,如 resume_context") .option("--cue <needle=scope>", "可重复;把命中的 task/summary cue 映射到目标 scope", collectRepeatedOption, []) .option("--neighbor-window-seconds <n>", "允许从邻近 observation 继承唯一 scope 的时间窗(秒)", "90") .option("--apply", "把有唯一建议 scope 的记录直接回填到文件") .option("--keep-global-id <id>", "可重复;把指定 manual-review 记录标记为已审阅且允许保留当前 global", collectRepeatedOption, []) .option("--keep-global-reason <reason>", "给 --keep-global-id 使用的审阅原因") .option("--json", "输出 JSON") .action(async (options) => { const cues = (options.cue as string[]).map(parseWorkflowObservationScopeCue); const keepGlobalIds = (options.keepGlobalId as string[]) || []; if (keepGlobalIds.length > 0 && typeof options.keepGlobalReason !== "string") { throw new Error("workflow-scope-review --keep-global-id requires --keep-global-reason."); } const review = reviewWorkflowObservationScopes({ currentScope: options.currentScope, source: options.source, workflowId: options.workflowId, cues, neighborWindowSeconds: parseLimitOption(options.neighborWindowSeconds, 90, 0, 3600), }); const keepGlobalResult = keepGlobalIds.length > 0 ? keepWorkflowObservationScopesGlobal(review, { ids: keepGlobalIds, reason: options.keepGlobalReason, }) : undefined; if (options.apply || keepGlobalResult) { const result = options.apply ? applyWorkflowObservationScopeSuggestions(review) : undefined; const latestReview = reviewWorkflowObservationScopes({ currentScope: options.currentScope, source: options.source, workflowId: options.workflowId, cues, neighborWindowSeconds: parseLimitOption(options.neighborWindowSeconds, 90, 0, 3600), }); if (options.json) { console.log(JSON.stringify({ review: latestReview, result, keepGlobalResult }, null, 2)); return; } console.log(formatWorkflowObservationScopeReview(latestReview)); if (keepGlobalResult) { console.log(); console.log(`Reviewed keep-global : ${keepGlobalResult.reviewedCount}`); console.log(`Skipped keep-global : ${keepGlobalResult.skippedCount}`); if (keepGlobalResult.unmatchedIds.length > 0) { console.log(`Unmatched ids : ${keepGlobalResult.unmatchedIds.join(", ")}`); } if (keepGlobalResult.ambiguousIds.length > 0) { console.log(`Ambiguous ids : ${keepGlobalResult.ambiguousIds.join(", ")}`); } } if (options.apply) { console.log(); console.log(`Applied : ${result?.updatedCount || 0}`); console.log(`Skipped : ${result?.skippedCount || 0}`); } return; } if (options.json) { console.log(JSON.stringify(review, null, 2)); return; } console.log(formatWorkflowObservationScopeReview(review)); }); program .command("checkpoint-gc") .description("Clean up old session checkpoints: archive stale rich ones, delete expired minimal ones") .option("--dry-run", "Preview actions without modifying files") .option("--keep-per-scope <n>", "Rich checkpoints to keep per scope (default: 5)") .option("--archive-after-days <n>", "Archive rich checkpoints older than N days (default: 30)") .option("--minimal-ttl-days <n>", "Delete minimal checkpoints older than N days (default: 7)") .option("--archive-ttl-days <n>", "Hard-delete archived files older than N days (default: 90)") .action(async (options) => { const { SessionCheckpointStore } = await import("./session-store.js"); const store = new SessionCheckpointStore(); const result = store.gc({ dryRun: Boolean(options.dryRun), keepPerScope: options.keepPerScope ? parseLimitOption(options.keepPerScope, 5, 1, 50) : undefined, archiveAfterDays: options.archiveAfterDays ? parseLimitOption(options.archiveAfterDays, 30, 1, 365) : undefined, minimalTtlDays: options.minimalTtlDays ? parseLimitOption(options.minimalTtlDays, 7, 1, 365) : undefined, archiveTtlDays: options.archiveTtlDays ? parseLimitOption(options.archiveTtlDays, 90, 1, 730) : undefined, }); console.log(options.dryRun ? "=== Checkpoint GC (dry run) ===\n" : "=== Checkpoint GC ===\n"); const archives = result.actions.filter((a) => a.action === "archive"); const deletes = result.actions.filter((a) => a.action === "delete"); if (archives.length > 0) { console.log(`Archive (${archives.length}):`); for (const a of archives) { console.log(` ${a.checkpointId.slice(0, 8)} ${a.resolvedScope} ${a.updatedAt.slice(0, 10)} ${a.reason}`); } console.log(); } if (deletes.length > 0) { console.log(`Delete (${deletes.length}):`); for (const a of deletes) { console.log(` ${a.checkpointId.slice(0, 8)} ${a.resolvedScope} ${a.updatedAt.slice(0, 10)} ${a.reason}`); } console.log(); } console.log([ `Kept : ${result.kept}`, `Archived : ${result.archived}`, `Deleted : ${result.deleted}`, `Errors : ${result.errors.length}`, ].join("\n")); if (result.errors.length > 0) { console.log("\nErrors:"); for (const e of result.errors) console.log(` ${e}`); } }); program .command("import <path>") .description("导入多格式对话文件到记忆索引") .option("-f, --format <format>", "格式: auto|claude-code|chatgpt|slack|claude-ai|plaintext", "auto") .option("-s, --scope <scope>", "目标 scope(必须)") .action(async (filePath: string, options) => { if (!options.scope) { console.error("❌ --scope is required"); process.exitCode = 1; return; } const resolvedPath = resolve(filePath); if (!existsSync(resolvedPath)) { console.error(`❌ File not found: ${resolvedPath}`); process.exitCode = 1; return; } const validFormats = ["auto", "claude-code", "chatgpt", "slack", "claude-ai", "plaintext"] as const; if (!validFormats.includes(options.format)) { console.error(`❌ Invalid format: ${options.format}. Must be one of: ${validFormats.join(", ")}`); process.exitCode = 1; return; } const config = loadConfig(); const { store, embedder, llm } = createComponents(config); const { importConversation } = await import("./conversation-importer.js"); const result = await importConversation( { store, embedder, llm }, resolvedPath, options.scope, options.format as "auto" | "claude-code" | "chatgpt" | "slack" | "claude-ai" | "plaintext", ); console.log(`\n📥 Import complete`); console.log(` Total messages: ${result.total}`); console.log(` Stored: ${result.stored}`); console.log(` Rejected: ${result.rejected}`); if (result.errors.length > 0) { console.log(` Errors: ${result.errors.length}`); for (const e of result.errors) console.log(` ${e}`); } }); program .command("doctor") .description("检查安装状态和配置") .option("--ci", "跳过 API key 在线验证(CI 模式)") .action(async (options) => { const results = await runDoctor({ ci: options.ci }); console.log(formatDoctorResults(results)); const hasFailure = results.some(r => r.status === "fail"); if (hasFailure) process.exitCode = 1; }); program .command("lint") .description("Run memory quality lint checks") .option("--scope <scope>", "Limit to a specific scope") .option("--json", "Output raw JSON instead of formatted report") .option("--verbose", "Show all individual findings") .action(async (options) => { const { runMemoryLint, formatMemoryLintReport } = await import("./memory-lint.js"); const config = loadConfig(); const { store } = createComponents(config); const report = await runMemoryLint({ store, scope: options.scope, verbose: options.verbose, }); if (options.json) { console.log(JSON.stringify(report, null, 2)); } else { console.log(); console.log(formatMemoryLintReport(report)); } }); program .command("graph") .description("Export interactive knowledge graph as HTML") .option("--scope <scope>", "Limit to a specific scope") .option("--max-nodes <n>", "Maximum nodes (default 200)", "200") .option("--out <path>", "Output file path") .option("--open", "Open in browser after export") .action(async (options) => { const { exportMemoryGraph, formatGraphExportResult } = await import("./graph-export.js"); const config = loadConfig(); const { store } = createComponents(config); const maxNodes = parseInt(options.maxNodes, 10) || 200; const { path, graph } = await exportMemoryGraph(store, { scope: options.scope, maxNodes, outputPath: options.out, }); console.log(); console.log(formatGraphExportResult(path, graph)); if (options.open) { const { execFileSync } = await import("node:child_process"); try { const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; execFileSync(cmd, [path], { stdio: "ignore" }); } catch {} } }); // Run loadDotEnv(); program.parseAsync(process.argv).catch((error) => { console.error(`RecallNest CLI error: ${errorMessage(error)}`); process.exitCode = 1; });