recallnest 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.claude-plugin/CLAUDE.md +29 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.env.example +3 -0
  4. package/.github/workflows/ci.yml +28 -0
  5. package/.mcp.json +9 -0
  6. package/CHANGELOG.md +51 -0
  7. package/CLAUDE.md +42 -0
  8. package/DEMO.md +94 -0
  9. package/EVAL.md +89 -0
  10. package/FAILURES.md +75 -0
  11. package/LICENSE +21 -0
  12. package/OPERATIONS.md +59 -0
  13. package/README.md +427 -0
  14. package/README_CN.md +425 -0
  15. package/ROADMAP.md +198 -0
  16. package/assets/recallnest-workbench.png +0 -0
  17. package/assets/ui/app.js +807 -0
  18. package/assets/ui/index.html +154 -0
  19. package/assets/ui/styles.css +605 -0
  20. package/assets/wechat-qr.jpg +0 -0
  21. package/config.json.example +38 -0
  22. package/daily-recall.sh +87 -0
  23. package/daily-reflections/2026-03-05.md +15 -0
  24. package/daily-reflections/2026-03-06.md +15 -0
  25. package/daily-reflections/2026-03-07.md +7 -0
  26. package/daily-reflections/2026-03-08.md +34 -0
  27. package/daily-reflections/2026-03-09.md +35 -0
  28. package/daily-reflections/2026-03-10.md +36 -0
  29. package/daily-reflections/2026-03-11.md +35 -0
  30. package/daily-reflections/2026-03-12.md +36 -0
  31. package/daily-reflections/2026-03-13.md +35 -0
  32. package/daily-reflections/2026-03-14.md +35 -0
  33. package/daily-reflections/2026-03-15.md +35 -0
  34. package/daily-reflections/2026-03-16.md +41 -0
  35. package/daily-reflections/2026-03-17.md +10 -0
  36. package/daily-reflections/2026-03-18.md +10 -0
  37. package/daily-reflections/2026-03-19.md +10 -0
  38. package/daily-reflections/2026-03-20.md +10 -0
  39. package/daily-reflections/2026-03-21.md +10 -0
  40. package/daily-reflections/2026-03-22.md +10 -0
  41. package/daily-reflections/2026-03-23.md +10 -0
  42. package/daily-reflections/2026-03-24.md +10 -0
  43. package/daily-reflections/2026-03-25.md +32 -0
  44. package/daily-reflections/2026-03-26.md +37 -0
  45. package/daily-reflections/2026-03-27.md +35 -0
  46. package/daily-reflections/2026-03-28.md +35 -0
  47. package/daily-reflections/2026-03-29.md +35 -0
  48. package/daily-reflections/2026-03-30.md +36 -0
  49. package/daily-reflections/2026-03-31.md +35 -0
  50. package/daily-reflections/2026-04-01.md +35 -0
  51. package/docs/api-reference.md +1291 -0
  52. package/docs/architecture.md +168 -0
  53. package/docs/memory-boundary-contract.md +226 -0
  54. package/docs/memory-categories.md +119 -0
  55. package/docs/pipeline-audit-ultramemory.md +177 -0
  56. package/docs/plans/2026-03-08-query-expansion.md +270 -0
  57. package/docs/plans/2026-03-16-continuity-layer.md +196 -0
  58. package/docs/plans/mcp-tiering-plan.md +126 -0
  59. package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
  60. package/docs/self-evolution.md +198 -0
  61. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
  62. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
  63. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
  64. package/eval/cases.json +85 -0
  65. package/eval/continuity/case-seeds.json +90 -0
  66. package/eval/continuity/cases.json +302 -0
  67. package/eval/continuity/memory-seeds.json +26 -0
  68. package/eval/continuity/pattern-seeds.json +100 -0
  69. package/eval/continuity/results-2026-03-16.md +117 -0
  70. package/eval/continuity/results-2026-03-19.md +453 -0
  71. package/eval/continuity/runbook.md +110 -0
  72. package/eval/continuity/seed-cases.md +54 -0
  73. package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
  74. package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
  75. package/eval/reports/2026-03-06-baseline.md +82 -0
  76. package/eval/reports/query-expansion.md +138 -0
  77. package/integrations/README.md +57 -0
  78. package/integrations/claude-code/README.md +133 -0
  79. package/integrations/claude-code/claude-md-snippet.md +31 -0
  80. package/integrations/claude-code/setup.sh +66 -0
  81. package/integrations/claude-code/smoke-continuity.sh +452 -0
  82. package/integrations/codex/README.md +49 -0
  83. package/integrations/codex/agents-md-snippet.md +31 -0
  84. package/integrations/codex/setup.sh +58 -0
  85. package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
  86. package/integrations/examples/langchain/memory-chain.py +111 -0
  87. package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
  88. package/integrations/gemini-cli/README.md +57 -0
  89. package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
  90. package/integrations/gemini-cli/setup.sh +64 -0
  91. package/integrations/shared/install-managed-block.sh +51 -0
  92. package/lm +19 -0
  93. package/package.json +50 -0
  94. package/plan-v1.2-distributable.md +105 -0
  95. package/scripts/archive-distilled-junk.ts +116 -0
  96. package/scripts/backfill-l0l1l2.ts +98 -0
  97. package/scripts/blind-scan.ts +588 -0
  98. package/scripts/cleanup-step1-exact-dups.ts +117 -0
  99. package/scripts/cleanup-step2-template-dups.ts +113 -0
  100. package/scripts/cleanup-step3-stale-content.ts +149 -0
  101. package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
  102. package/scripts/delete-distilled-junk.ts +88 -0
  103. package/scripts/distill-facts.ts +305 -0
  104. package/scripts/health-check.ts +343 -0
  105. package/scripts/incremental-ingest.sh +71 -0
  106. package/scripts/mlp-health-check.ts +382 -0
  107. package/scripts/pull-claude-desktop.ts +515 -0
  108. package/scripts/re-ingest-cc.sh +66 -0
  109. package/scripts/rebalance-memory-health.ts +181 -0
  110. package/scripts/run-desktop-ingest.sh +11 -0
  111. package/scripts/start-server.sh +12 -0
  112. package/scripts/viz-dashboard.html +423 -0
  113. package/scripts/viz-export.ts +703 -0
  114. package/src/__tests__/access-tracker-novelty.test.ts +228 -0
  115. package/src/__tests__/auto-consolidation.test.ts +168 -0
  116. package/src/__tests__/auto-recall.test.ts +131 -0
  117. package/src/__tests__/batch-dedup.test.ts +65 -0
  118. package/src/__tests__/capture-engine.test.ts +1142 -0
  119. package/src/__tests__/capture-heuristic.test.ts +171 -0
  120. package/src/__tests__/cascade-forget.test.ts +210 -0
  121. package/src/__tests__/category-min-scores.test.ts +33 -0
  122. package/src/__tests__/confidence-tracker.test.ts +196 -0
  123. package/src/__tests__/conflict-advisor.test.ts +206 -0
  124. package/src/__tests__/conflict-escalation.test.ts +149 -0
  125. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  126. package/src/__tests__/conflict-output.test.ts +85 -0
  127. package/src/__tests__/conflict-store.test.ts +374 -0
  128. package/src/__tests__/consolidation-engine.test.ts +181 -0
  129. package/src/__tests__/context-composer-pins.test.ts +64 -0
  130. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  131. package/src/__tests__/context-renderer.test.ts +91 -0
  132. package/src/__tests__/continuity-eval.test.ts +275 -0
  133. package/src/__tests__/continuity-schema.test.ts +97 -0
  134. package/src/__tests__/core-recall-eval.test.ts +235 -0
  135. package/src/__tests__/core-summary.test.ts +109 -0
  136. package/src/__tests__/dedup-actions.test.ts +58 -0
  137. package/src/__tests__/deterministic-id.test.ts +39 -0
  138. package/src/__tests__/doctor.test.ts +303 -0
  139. package/src/__tests__/drill-down.test.ts +108 -0
  140. package/src/__tests__/embedder.test.ts +122 -0
  141. package/src/__tests__/entity-resolver.test.ts +58 -0
  142. package/src/__tests__/event-segmenter.test.ts +153 -0
  143. package/src/__tests__/hotness-blend.test.ts +76 -0
  144. package/src/__tests__/ingest.test.ts +360 -0
  145. package/src/__tests__/kg-extractor.test.ts +184 -0
  146. package/src/__tests__/kg-store.test.ts +198 -0
  147. package/src/__tests__/llm-consolidation.test.ts +226 -0
  148. package/src/__tests__/memory-boundaries.test.ts +237 -0
  149. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  150. package/src/__tests__/memory-output.test.ts +215 -0
  151. package/src/__tests__/multi-vector.test.ts +242 -0
  152. package/src/__tests__/ppr-traversal.test.ts +256 -0
  153. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  154. package/src/__tests__/preference-matcher.test.ts +230 -0
  155. package/src/__tests__/preference-slots.test.ts +106 -0
  156. package/src/__tests__/prospective-memory.test.ts +283 -0
  157. package/src/__tests__/query-entity-detector.test.ts +171 -0
  158. package/src/__tests__/query-expander.test.ts +34 -0
  159. package/src/__tests__/recall-mode.test.ts +64 -0
  160. package/src/__tests__/result-synthesizer.test.ts +198 -0
  161. package/src/__tests__/resume-context.test.ts +4743 -0
  162. package/src/__tests__/retrieval-trace.test.ts +213 -0
  163. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  164. package/src/__tests__/retroactive-boost.test.ts +223 -0
  165. package/src/__tests__/scope-inventory.test.ts +200 -0
  166. package/src/__tests__/scope-policy.test.ts +69 -0
  167. package/src/__tests__/session-checkpoint.test.ts +259 -0
  168. package/src/__tests__/session-gc.test.ts +215 -0
  169. package/src/__tests__/store-scope-audit.test.ts +81 -0
  170. package/src/__tests__/temporal-parser.test.ts +241 -0
  171. package/src/__tests__/term-registry.test.ts +121 -0
  172. package/src/__tests__/tool-output-compressor.test.ts +133 -0
  173. package/src/__tests__/version-manager.test.ts +213 -0
  174. package/src/__tests__/workflow-observation.test.ts +400 -0
  175. package/src/access-tracker.ts +316 -0
  176. package/src/adaptive-retrieval.ts +64 -0
  177. package/src/api-server.ts +667 -0
  178. package/src/asset-sync.ts +79 -0
  179. package/src/auto-consolidation.ts +133 -0
  180. package/src/auto-gc.ts +124 -0
  181. package/src/auto-recall.ts +95 -0
  182. package/src/capture-engine.ts +931 -0
  183. package/src/capture-heuristic.ts +177 -0
  184. package/src/cascade-forget.ts +135 -0
  185. package/src/chunker.ts +253 -0
  186. package/src/cli.ts +1917 -0
  187. package/src/confidence-tracker.ts +166 -0
  188. package/src/conflict-advisor.ts +433 -0
  189. package/src/conflict-engine.ts +181 -0
  190. package/src/conflict-escalation.ts +138 -0
  191. package/src/conflict-lifecycle.ts +67 -0
  192. package/src/conflict-output.ts +218 -0
  193. package/src/conflict-schema.ts +130 -0
  194. package/src/conflict-store.ts +127 -0
  195. package/src/consolidation-engine.ts +256 -0
  196. package/src/context-composer-pins.ts +183 -0
  197. package/src/context-composer-queries.ts +121 -0
  198. package/src/context-composer-scope.ts +56 -0
  199. package/src/context-composer-stable-selection.ts +200 -0
  200. package/src/context-composer-stable.ts +217 -0
  201. package/src/context-composer-task-fallbacks.ts +36 -0
  202. package/src/context-composer-task-ranking.ts +197 -0
  203. package/src/context-composer-task-results.ts +137 -0
  204. package/src/context-composer-task-selection.ts +342 -0
  205. package/src/context-composer-text.ts +57 -0
  206. package/src/context-composer.ts +300 -0
  207. package/src/context-renderer.ts +179 -0
  208. package/src/decay-engine.ts +189 -0
  209. package/src/doctor.ts +543 -0
  210. package/src/embedder.ts +557 -0
  211. package/src/entity-resolver.ts +131 -0
  212. package/src/eval.ts +632 -0
  213. package/src/event-segmenter.ts +216 -0
  214. package/src/ingest.ts +1822 -0
  215. package/src/kg-extractor.ts +217 -0
  216. package/src/kg-store.ts +353 -0
  217. package/src/legacy-scope-review.ts +53 -0
  218. package/src/llm-client.ts +629 -0
  219. package/src/llm-consolidation.ts +168 -0
  220. package/src/mcp-server.ts +1337 -0
  221. package/src/memory-assets.ts +475 -0
  222. package/src/memory-boundaries.ts +354 -0
  223. package/src/memory-health-rebalance.ts +220 -0
  224. package/src/memory-output.ts +474 -0
  225. package/src/memory-schema.ts +184 -0
  226. package/src/multi-vector.ts +234 -0
  227. package/src/noise-filter.ts +155 -0
  228. package/src/ppr-traversal.ts +279 -0
  229. package/src/preference-matcher.ts +174 -0
  230. package/src/preference-slots.ts +259 -0
  231. package/src/prospective-memory.ts +198 -0
  232. package/src/query-entity-detector.ts +207 -0
  233. package/src/query-expander.ts +98 -0
  234. package/src/result-synthesizer.ts +114 -0
  235. package/src/retrieval-profiles.ts +101 -0
  236. package/src/retrieval-trace.ts +94 -0
  237. package/src/retriever.ts +1338 -0
  238. package/src/retroactive-boost.ts +154 -0
  239. package/src/rif.ts +73 -0
  240. package/src/runtime-config.ts +172 -0
  241. package/src/schema-utils.ts +56 -0
  242. package/src/scope-inventory.ts +310 -0
  243. package/src/scope-policy.ts +142 -0
  244. package/src/session-engine.ts +168 -0
  245. package/src/session-output.ts +96 -0
  246. package/src/session-schema.ts +60 -0
  247. package/src/session-store.ts +293 -0
  248. package/src/stderr-log.ts +16 -0
  249. package/src/store.ts +903 -0
  250. package/src/temporal-parser.ts +318 -0
  251. package/src/term-registry.ts +791 -0
  252. package/src/tool-output-compressor.ts +115 -0
  253. package/src/tracker.ts +57 -0
  254. package/src/ui-server.ts +430 -0
  255. package/src/version-manager.ts +172 -0
  256. package/src/workflow-observation-engine.ts +316 -0
  257. package/src/workflow-observation-managed.ts +83 -0
  258. package/src/workflow-observation-output.ts +86 -0
  259. package/src/workflow-observation-schema.ts +87 -0
  260. package/src/workflow-observation-scope-review.ts +409 -0
  261. package/src/workflow-observation-store.ts +69 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1917 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * RecallNest CLI — MCP-native memory search and distillation
4
+ *
5
+ * Usage:
6
+ * lm search "query keywords"
7
+ * lm ingest --source cc --limit 5
8
+ * lm ingest --source all
9
+ * lm stats
10
+ */
11
+
12
+ import { Command } from "commander";
13
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { homedir } from "node:os";
16
+
17
+ import type { RetrievalResult } from "./retriever.js";
18
+ import { applyRetrievalProfile, listRetrievalProfiles } from "./retrieval-profiles.js";
19
+ import { distillResults, formatExplainResults, formatSearchResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js";
20
+ import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsset, getExportsDir, listDirtyBriefAssets, listMemoryAssets, listPinAssets, pinSummaryLine, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js";
21
+ import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
22
+ import { createComponents, createStoreOnly, expandHome, loadConfig, loadDotEnv, type LocalMemoryConfig } from "./runtime-config.js";
23
+ import {
24
+ formatDedupReasonSummary,
25
+ getDedupSkipRate,
26
+ type IngestResult,
27
+ ingestCCTranscripts,
28
+ ingestCodexSessions,
29
+ ingestGeminiSessions,
30
+ ingestMarkdownFiles,
31
+ } from "./ingest.js";
32
+ import { runDoctor, formatDoctorResults } from "./doctor.js";
33
+ import { persistCaseMemory, persistMemory, persistWorkflowPattern } from "./capture-engine.js";
34
+ import {
35
+ CaseMemoryInputSchema,
36
+ type CaseMemoryInput,
37
+ StoreMemoryInputSchema,
38
+ type StoreMemoryInput,
39
+ WorkflowPatternInputSchema,
40
+ type WorkflowPatternInput,
41
+ } from "./memory-schema.js";
42
+ import { classifyLegacyScope, type MemoryEntry } from "./store.js";
43
+ import { ConflictStatusSchema } from "./conflict-schema.js";
44
+ import { resolveConflictCandidate } from "./conflict-engine.js";
45
+ import { escalateConflicts } from "./conflict-escalation.js";
46
+ import { ConflictCandidateStore } from "./conflict-store.js";
47
+ import { formatConflictAudit, formatConflictAuditMarkdown, formatConflictClusters, formatConflictEscalation, formatConflictList, formatConflictRecord, formatConflictResolution } from "./conflict-output.js";
48
+ import { CONFLICT_ATTENTION_LEVELS, parseConflictAttention, summarizeConflictLifecycle } from "./conflict-lifecycle.js";
49
+ import { buildConflictAuditSummary, clusterConflicts, summarizeConflictAdvice } from "./conflict-advisor.js";
50
+ import { WorkflowObservationOutcomeSchema } from "./workflow-observation-schema.js";
51
+ import { buildWorkflowEvidence, buildWorkflowObservationRecord, inspectWorkflowDashboard, inspectWorkflowHealth } from "./workflow-observation-engine.js";
52
+ import { formatWorkflowEvidencePack, formatWorkflowHealthDashboard, formatWorkflowHealthReport, formatWorkflowObservationSaved } from "./workflow-observation-output.js";
53
+ import { WorkflowObservationStore } from "./workflow-observation-store.js";
54
+ import {
55
+ applyWorkflowObservationScopeSuggestions,
56
+ formatWorkflowObservationScopeReview,
57
+ keepWorkflowObservationScopesGlobal,
58
+ parseWorkflowObservationScopeCue,
59
+ reviewWorkflowObservationScopes,
60
+ } from "./workflow-observation-scope-review.js";
61
+ import { collectScopeInventory, formatScopeInventoryReport } from "./scope-inventory.js";
62
+ import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
63
+
64
+ /**
65
+ * Auto-detect Claude Code transcript directory.
66
+ * Scans ~/.claude/projects/ for subdirectories containing .jsonl files.
67
+ */
68
+ function detectCCPath(): string | null {
69
+ const projectsDir = join(homedir(), ".claude", "projects");
70
+ if (!existsSync(projectsDir)) return null;
71
+ try {
72
+ const dirs = readdirSync(projectsDir, { withFileTypes: true })
73
+ .filter(d => d.isDirectory())
74
+ .map(d => join(projectsDir, d.name));
75
+ // Pick the directory with most .jsonl files
76
+ let best = "";
77
+ let bestCount = 0;
78
+ for (const dir of dirs) {
79
+ const jsonlCount = readdirSync(dir).filter(f => f.endsWith(".jsonl")).length;
80
+ if (jsonlCount > bestCount) {
81
+ bestCount = jsonlCount;
82
+ best = dir;
83
+ }
84
+ }
85
+ return best || null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve "auto" paths in config to actual directories.
93
+ */
94
+ function resolveSourcePath(source: string, key: string): string {
95
+ if (source !== "auto") return expandHome(source);
96
+
97
+ if (key === "cc") {
98
+ const detected = detectCCPath();
99
+ if (!detected) throw new Error("Could not auto-detect Claude Code transcript path. Set sources.cc.path in config.json.");
100
+ return detected;
101
+ }
102
+ if (key === "memory") {
103
+ const ccPath = detectCCPath();
104
+ if (!ccPath) throw new Error("Could not auto-detect memory path. Set sources.memory.path in config.json.");
105
+ return join(ccPath, "memory");
106
+ }
107
+ throw new Error(`Cannot auto-detect path for source "${key}". Set it manually in config.json.`);
108
+ }
109
+
110
+ function formatIngestSummary(result: IngestResult): string {
111
+ return `${result.filesProcessed} files, ${result.chunksIngested} ingested, ${result.chunksDeduped} deduped (${formatDedupReasonSummary(result)}), ${result.errors.length} errors`;
112
+ }
113
+
114
+ function maybeWarnHighCCDedupRate(result: IngestResult): void {
115
+ if (result.source !== "cc") return;
116
+ const dedupRate = getDedupSkipRate(result);
117
+ if (dedupRate <= 0.85) return;
118
+ console.log(` ⚠️ dedup 率异常: ${(dedupRate * 100).toFixed(1)}%`);
119
+ }
120
+
121
+ // ============================================================================
122
+ // CLI
123
+ // ============================================================================
124
+
125
+ const program = new Command();
126
+
127
+ program
128
+ .name("recallnest")
129
+ .description("本地优先 AI 对话记忆搜索与蒸馏层")
130
+ .version("1.4.0");
131
+
132
+ async function runRetrievalView(
133
+ view: "search" | "explain" | "distill",
134
+ query: string,
135
+ options: {
136
+ limit?: string;
137
+ scope?: string;
138
+ sessionId?: string;
139
+ allScopes?: boolean;
140
+ json?: boolean;
141
+ profile?: string;
142
+ explain?: boolean;
143
+ trace?: boolean;
144
+ },
145
+ ) {
146
+ const config = loadConfig();
147
+ const { retriever, profile } = createComponents(config, options.profile);
148
+
149
+ const limit = parseLimitOption(options.limit, 5, 1, 20);
150
+
151
+ // Build trace collector when --trace is set
152
+ let trace: import("./retrieval-trace.js").TraceCollector | undefined;
153
+ if (options.trace) {
154
+ const { TraceCollector } = await import("./retrieval-trace.js");
155
+ trace = new TraceCollector();
156
+ }
157
+
158
+ const results = await retriever.retrieve(buildRetrievalContext({
159
+ query,
160
+ limit,
161
+ scope: options.scope,
162
+ sessionId: options.sessionId,
163
+ allScopes: Boolean(options.allScopes),
164
+ source: "cli",
165
+ trace,
166
+ }, {
167
+ operation: `cli:${view}`,
168
+ }));
169
+
170
+ if (view === "search" && options.json) {
171
+ console.log(
172
+ JSON.stringify(
173
+ {
174
+ query,
175
+ profile: profile.name,
176
+ results: results.map((r) => ({
177
+ score: r.score,
178
+ scope: r.entry.scope,
179
+ text: r.entry.text,
180
+ metadata: r.entry.metadata,
181
+ retrievalPath: Object.keys(r.sources).filter((key) => Boolean((r.sources as any)[key])),
182
+ })),
183
+ },
184
+ null,
185
+ 2,
186
+ ),
187
+ );
188
+ return;
189
+ }
190
+
191
+ // Print trace summary before results when --trace is set
192
+ if (trace) {
193
+ const mode = retriever.getConfig().mode === "vector" ? "vector" as const : "hybrid" as const;
194
+ console.log(trace.summarize(query, mode));
195
+ console.log(""); // blank separator
196
+ }
197
+
198
+ const context = { query, profile: profile.name };
199
+ const rendered = view === "explain"
200
+ ? formatExplainResults(results, context)
201
+ : view === "distill"
202
+ ? distillResults(results, context)
203
+ : formatSearchResults(results, context);
204
+
205
+ console.log(rendered);
206
+ }
207
+
208
+ function parseLimitOption(value: string | undefined, fallback: number, min = 1, max = 50): number {
209
+ const parsed = Number.parseInt(String(value ?? ""), 10);
210
+ if (!Number.isFinite(parsed)) return fallback;
211
+ return Math.min(max, Math.max(min, parsed));
212
+ }
213
+
214
+ function parseRequiredLimitOption(value: string | undefined, field: string, min = 1, max = 50): number {
215
+ const parsed = Number.parseInt(String(value ?? ""), 10);
216
+ if (!Number.isFinite(parsed)) {
217
+ throw new Error(`${field} must be an integer`);
218
+ }
219
+ return Math.min(max, Math.max(min, parsed));
220
+ }
221
+
222
+ function toScopeFilter(scope?: string): string[] | undefined {
223
+ const trimmed = typeof scope === "string" ? scope.trim() : "";
224
+ return trimmed ? [trimmed] : undefined;
225
+ }
226
+
227
+ function errorMessage(error: unknown): string {
228
+ return error instanceof Error ? error.message : String(error);
229
+ }
230
+
231
+ function parseExportFormat(value: string | undefined, fallback: "md" | "json" = "md"): "md" | "json" {
232
+ if (!value) return fallback;
233
+ if (value === "md" || value === "json") return value;
234
+ throw new Error(`Invalid export format: ${value}`);
235
+ }
236
+
237
+ function splitCsvOption(value?: string): string[] {
238
+ return String(value || "")
239
+ .split(",")
240
+ .map((item) => item.trim())
241
+ .filter(Boolean);
242
+ }
243
+
244
+ function defaultConflictAuditExportPath(format: "md" | "json", canonicalKey?: string): string {
245
+ const timestamp = new Date().toISOString().replace(/[:]/g, "-").replace(/\..+/, "");
246
+ const stem = canonicalKey
247
+ ? canonicalKey
248
+ .toLowerCase()
249
+ .replace(/[^a-z0-9\p{Script=Han}]+/giu, "-")
250
+ .replace(/^-+|-+$/g, "")
251
+ .slice(0, 48) || "conflict-audit"
252
+ : "conflict-audit";
253
+ return join(getExportsDir(), `${timestamp}-${stem}.${format}`);
254
+ }
255
+
256
+ function parseConflictStatusOption(value?: string): "open" | "accepted-incoming" | "kept-existing" | undefined {
257
+ if (!value) return undefined;
258
+ const parsed = ConflictStatusSchema.safeParse(value);
259
+ if (!parsed.success) {
260
+ throw new Error(`Invalid conflict status: ${value}`);
261
+ }
262
+ return parsed.data;
263
+ }
264
+
265
+ function filterConflictsByAttention<T extends { status: string; createdAt: string; updatedAt: string; reopenCount?: number; lastReopenedAt?: string }>(
266
+ records: T[],
267
+ attention?: string,
268
+ staleOnly?: boolean,
269
+ ): T[] {
270
+ if (!attention && !staleOnly) return records;
271
+ return records.filter((record) => {
272
+ const lifecycle = summarizeConflictLifecycle(record as any);
273
+ if (staleOnly) {
274
+ return lifecycle.attention === "stale" || lifecycle.attention === "escalated";
275
+ }
276
+ return lifecycle.attention === attention;
277
+ });
278
+ }
279
+
280
+ function workflowPatternSeedsPath(file?: string): string {
281
+ return file
282
+ ? resolve(file)
283
+ : resolve(import.meta.dir, "../eval/continuity/pattern-seeds.json");
284
+ }
285
+
286
+ function caseMemorySeedsPath(file?: string): string {
287
+ return file
288
+ ? resolve(file)
289
+ : resolve(import.meta.dir, "../eval/continuity/case-seeds.json");
290
+ }
291
+
292
+ function memorySeedsPath(file?: string): string {
293
+ return file
294
+ ? resolve(file)
295
+ : resolve(import.meta.dir, "../eval/continuity/memory-seeds.json");
296
+ }
297
+
298
+ function parseMetadata(raw?: string): Record<string, unknown> {
299
+ try {
300
+ return JSON.parse(raw || "{}") as Record<string, unknown>;
301
+ } catch {
302
+ return {};
303
+ }
304
+ }
305
+
306
+ function extractWorkflowPatternTitle(entry: MemoryEntry): string {
307
+ const metadata = parseMetadata(entry.metadata);
308
+ const workflowPattern = metadata.workflowPattern;
309
+ if (workflowPattern && typeof workflowPattern === "object" && typeof (workflowPattern as any).title === "string") {
310
+ return String((workflowPattern as any).title).trim();
311
+ }
312
+
313
+ const match = entry.text.match(/^Workflow pattern:\s*(.+)$/im);
314
+ return match?.[1]?.trim() || "";
315
+ }
316
+
317
+ function extractCaseMemoryTitle(entry: MemoryEntry): string {
318
+ const metadata = parseMetadata(entry.metadata);
319
+ const caseMemory = metadata.caseMemory;
320
+ if (caseMemory && typeof caseMemory === "object" && typeof (caseMemory as any).title === "string") {
321
+ return String((caseMemory as any).title).trim();
322
+ }
323
+
324
+ const match = entry.text.match(/^Case:\s*(.+)$/im);
325
+ return match?.[1]?.trim() || "";
326
+ }
327
+
328
+ function workflowPatternIdentity(title: string, scope: string): string {
329
+ return `${scope.toLowerCase()}::${title.trim().toLowerCase()}`;
330
+ }
331
+
332
+ function caseMemoryIdentity(title: string, scope: string): string {
333
+ return `${scope.toLowerCase()}::${title.trim().toLowerCase()}`;
334
+ }
335
+
336
+ function normalizeSeedIdentityValue(value?: string): string {
337
+ return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
338
+ }
339
+
340
+ function extractStoreMemoryCanonicalKey(entry: MemoryEntry): string {
341
+ const metadata = parseMetadata(entry.metadata);
342
+ return typeof metadata.canonicalKey === "string" ? metadata.canonicalKey.trim() : "";
343
+ }
344
+
345
+ function storeMemoryIdentity(params: {
346
+ category: string;
347
+ scope: string;
348
+ text: string;
349
+ canonicalKey?: string;
350
+ }): string {
351
+ const identity = normalizeSeedIdentityValue(params.canonicalKey) || normalizeSeedIdentityValue(params.text);
352
+ return `${params.scope.toLowerCase()}::${params.category.toLowerCase()}::${identity}`;
353
+ }
354
+
355
+ function normalizeWorkflowPatternSeed(raw: unknown, defaults: {
356
+ scope?: string;
357
+ source?: string;
358
+ }): WorkflowPatternInput {
359
+ const record = raw && typeof raw === "object" && !Array.isArray(raw)
360
+ ? raw as Record<string, unknown>
361
+ : {};
362
+ return WorkflowPatternInputSchema.parse({
363
+ ...record,
364
+ scope: typeof record.scope === "string" ? record.scope : defaults.scope,
365
+ source: typeof record.source === "string" ? record.source : defaults.source,
366
+ });
367
+ }
368
+
369
+ function normalizeCaseMemorySeed(raw: unknown, defaults: {
370
+ scope?: string;
371
+ source?: string;
372
+ }): CaseMemoryInput {
373
+ const record = raw && typeof raw === "object" && !Array.isArray(raw)
374
+ ? raw as Record<string, unknown>
375
+ : {};
376
+ return CaseMemoryInputSchema.parse({
377
+ ...record,
378
+ scope: typeof record.scope === "string" ? record.scope : defaults.scope,
379
+ source: typeof record.source === "string" ? record.source : defaults.source,
380
+ });
381
+ }
382
+
383
+ function normalizeStoreMemorySeed(raw: unknown, defaults: {
384
+ scope?: string;
385
+ source?: string;
386
+ }): StoreMemoryInput {
387
+ const record = raw && typeof raw === "object" && !Array.isArray(raw)
388
+ ? raw as Record<string, unknown>
389
+ : {};
390
+ return StoreMemoryInputSchema.parse({
391
+ ...record,
392
+ scope: typeof record.scope === "string" ? record.scope : defaults.scope,
393
+ source: typeof record.source === "string" ? record.source : defaults.source,
394
+ });
395
+ }
396
+
397
+ function seedTextPreview(text: string, maxLen = 72): string {
398
+ const compact = text.replace(/\s+/g, " ").trim();
399
+ return compact.length <= maxLen ? compact : `${compact.slice(0, maxLen - 3).trimEnd()}...`;
400
+ }
401
+
402
+ function formatLegacyScope(value: string | null): string {
403
+ const kind = classifyLegacyScope(value);
404
+ if (kind === "missing") return "<missing>";
405
+ if (kind === "empty") return "<empty>";
406
+ return value ?? "<missing>";
407
+ }
408
+
409
+ function collectRepeatedOption(value: string, previous: string[] = []): string[] {
410
+ return [...previous, value];
411
+ }
412
+
413
+ function entryToRetrievalResult(entry: Awaited<ReturnType<MemoryStore["get"]>>): RetrievalResult {
414
+ if (!entry) {
415
+ throw new Error("Memory entry not found.");
416
+ }
417
+ return {
418
+ entry,
419
+ score: entry.importance || 0.7,
420
+ sources: {
421
+ fused: { score: entry.importance || 0.7 },
422
+ },
423
+ };
424
+ }
425
+
426
+ // ─── search ──────────────────────────────────────────────────────────────────
427
+
428
+ program
429
+ .command("search <query>")
430
+ .description("搜索记忆(支持 profile / explain)")
431
+ .option("-n, --limit <n>", "返回结果数", "5")
432
+ .option("-s, --scope <scope>", "显式限定 scope")
433
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
434
+ .option("--all-scopes", "显式允许跨 scope 搜索")
435
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "default")
436
+ .option("--explain", "显示命中原因与检索路径")
437
+ .option("--trace", "显示检索管道各阶段 trace(调试用)")
438
+ .option("--json", "JSON 格式输出")
439
+ .action(async (query: string, options) => {
440
+ await runRetrievalView(options.explain ? "explain" : "search", query, options);
441
+ });
442
+
443
+ program
444
+ .command("explain <query>")
445
+ .description("解释为什么召回这些记忆")
446
+ .option("-n, --limit <n>", "返回结果数", "5")
447
+ .option("-s, --scope <scope>", "显式限定 scope")
448
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
449
+ .option("--all-scopes", "显式允许跨 scope 搜索")
450
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "default")
451
+ .action(async (query: string, options) => {
452
+ await runRetrievalView("explain", query, options);
453
+ });
454
+
455
+ program
456
+ .command("distill <query>")
457
+ .description("把命中结果蒸馏成可复用 briefing")
458
+ .option("-n, --limit <n>", "返回结果数", "8")
459
+ .option("-s, --scope <scope>", "显式限定 scope")
460
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
461
+ .option("--all-scopes", "显式允许跨 scope 搜索")
462
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "writing")
463
+ .action(async (query: string, options) => {
464
+ await runRetrievalView("distill", query, options);
465
+ });
466
+
467
+ program
468
+ .command("profiles")
469
+ .description("显示可用检索画像")
470
+ .action(() => {
471
+ const rows = [
472
+ "Profile Label What It Optimizes",
473
+ "----------- -------------- -----------------------------------------------",
474
+ ];
475
+ for (const profile of listRetrievalProfiles()) {
476
+ rows.push(
477
+ `${profile.name.padEnd(11)} ${profile.label.padEnd(14)} ${profile.description}`,
478
+ );
479
+ }
480
+ console.log(rows.join("\n"));
481
+ });
482
+
483
+ program
484
+ .command("pin <memoryId>")
485
+ .description("把一条命中记忆提升为 pinned asset")
486
+ .option("-s, --scope <scope>", "显式限定 scope")
487
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
488
+ .option("--all-scopes", "显式允许跨 scope 读取")
489
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "default")
490
+ .option("-q, --query <query>", "记录这条记忆来自哪个查询")
491
+ .option("-t, --title <title>", "自定义 asset 标题")
492
+ .option("--summary <summary>", "自定义 asset 摘要")
493
+ .action(async (memoryId: string, options) => {
494
+ const config = loadConfig();
495
+ const { store, embedder } = createComponents(config, options.profile);
496
+ const scopeSelection = resolveScopeSelection({
497
+ scope: options.scope,
498
+ sessionId: options.sessionId,
499
+ allScopes: Boolean(options.allScopes),
500
+ operation: "cli:pin",
501
+ });
502
+ const entry = await store.get(memoryId, scopeSelection.scopeFilter);
503
+ if (!entry) {
504
+ console.log(`Memory not found: ${memoryId}`);
505
+ return;
506
+ }
507
+
508
+ const pinnedImportance = Math.max(entry.importance || 0.7, 0.95);
509
+ await store.update(entry.id, { importance: pinnedImportance }, scopeSelection.scopeFilter);
510
+
511
+ const asset = buildPinAsset(entryToRetrievalResult(entry), {
512
+ title: options.title,
513
+ summary: options.summary,
514
+ query: options.query,
515
+ profile: options.profile,
516
+ });
517
+ const path = savePinAsset(asset);
518
+ await indexPinnedAsset(store, embedder, asset);
519
+
520
+ console.log([
521
+ `Pinned : ${asset.id.slice(0, 8)}`,
522
+ `Memory : ${entry.id.slice(0, 8)} (${entry.scope})`,
523
+ `Title : ${asset.title}`,
524
+ `Path : ${path}`,
525
+ ].join("\n"));
526
+ });
527
+
528
+ program
529
+ .command("brief <query>")
530
+ .description("把一组召回结果沉淀成 structured memory brief")
531
+ .option("-n, --limit <n>", "返回结果数", "8")
532
+ .option("-s, --scope <scope>", "显式限定 scope")
533
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
534
+ .option("--all-scopes", "显式允许跨 scope 搜索")
535
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "writing")
536
+ .option("-t, --title <title>", "自定义 brief 标题")
537
+ .action(async (query: string, options) => {
538
+ const config = loadConfig();
539
+ const { retriever, profile, store, embedder } = createComponents(config, options.profile);
540
+ const limit = parseLimitOption(options.limit, 8, 1, 20);
541
+ const results = await retriever.retrieve(buildRetrievalContext({
542
+ query,
543
+ limit,
544
+ scope: options.scope,
545
+ sessionId: options.sessionId,
546
+ allScopes: Boolean(options.allScopes),
547
+ source: "cli",
548
+ }, {
549
+ operation: "cli:brief",
550
+ }));
551
+ if (results.length === 0) {
552
+ console.log("No results found.");
553
+ return;
554
+ }
555
+
556
+ const briefSeedResults = selectBriefSeedResults(results);
557
+ const summary = summarizeResults(briefSeedResults, { query, profile: profile.name });
558
+ const asset = buildBriefAsset(summary, { title: options.title });
559
+ const path = saveBriefAsset(asset);
560
+ await indexAsset(store, embedder, asset);
561
+
562
+ console.log([
563
+ `Brief : ${asset.id.slice(0, 8)}`,
564
+ `Title : ${asset.title}`,
565
+ `Hits : ${asset.hits}`,
566
+ `Path : ${path}`,
567
+ ].join("\n"));
568
+ });
569
+
570
+ program
571
+ .command("pins")
572
+ .description("列出最近的 pinned assets")
573
+ .option("-n, --limit <n>", "返回结果数", "10")
574
+ .action((options) => {
575
+ const limit = parseLimitOption(options.limit, 10, 1, 50);
576
+ const rows = listPinAssets(limit);
577
+ if (rows.length === 0) {
578
+ console.log("No pinned assets yet.");
579
+ return;
580
+ }
581
+ console.log("Pin ID Title Scope Date");
582
+ console.log("-------- ----- ----- ----------");
583
+ for (const row of rows) {
584
+ console.log(pinSummaryLine(row));
585
+ }
586
+ });
587
+
588
+ program
589
+ .command("assets")
590
+ .description("列出最近的 structured memory assets")
591
+ .option("-n, --limit <n>", "返回结果数", "12")
592
+ .action((options) => {
593
+ const limit = parseLimitOption(options.limit, 12, 1, 50);
594
+ const rows = listMemoryAssets(limit);
595
+ if (rows.length === 0) {
596
+ console.log("No assets yet.");
597
+ return;
598
+ }
599
+ console.log("Asset ID Kind Title Scope / Sources Date");
600
+ console.log("-------- ----- ----- --------------- ----------");
601
+ for (const row of rows) {
602
+ console.log(assetSummaryLine(row));
603
+ }
604
+ });
605
+
606
+ program
607
+ .command("clean-briefs")
608
+ .description("归档旧规则生成的脏 brief,并从索引删除对应 asset scope")
609
+ .option("--apply", "执行清理;默认只预览")
610
+ .action(async (options) => {
611
+ const dirty = listDirtyBriefAssets();
612
+ if (dirty.length === 0) {
613
+ console.log("No dirty briefs found.");
614
+ return;
615
+ }
616
+
617
+ if (!options.apply) {
618
+ console.log("Dirty briefs detected:");
619
+ for (const item of dirty) {
620
+ console.log(`${item.id.slice(0, 8)} ${item.title} [${item.scope}] ${item.reasons.join("; ")}`);
621
+ }
622
+ console.log("\nRun with --apply to archive files and delete their indexed asset scopes.");
623
+ return;
624
+ }
625
+
626
+ const config = loadConfig();
627
+ const { store } = createComponents(config);
628
+ let archived = 0;
629
+ let deleted = 0;
630
+
631
+ for (const item of dirty) {
632
+ archiveDirtyBriefAsset(item);
633
+ archived += 1;
634
+ deleted += await store.bulkDelete([item.scope]);
635
+ }
636
+
637
+ console.log([
638
+ `Dirty briefs: ${dirty.length}`,
639
+ `Archived : ${archived}`,
640
+ `Index rows : ${deleted}`,
641
+ ].join("\n"));
642
+ });
643
+
644
+ program
645
+ .command("export <query>")
646
+ .description("导出检索与蒸馏结果到 markdown/json")
647
+ .option("-n, --limit <n>", "返回结果数", "8")
648
+ .option("-s, --scope <scope>", "显式限定 scope")
649
+ .option("--session-id <sessionId>", "当前 sessionId;未传 scope 时会推断为 session:<id>")
650
+ .option("--all-scopes", "显式允许跨 scope 搜索")
651
+ .option("-p, --profile <profile>", "检索画像:default / writing / debug / fact-check", "writing")
652
+ .option("-f, --format <format>", "导出格式:md / json", "md")
653
+ .action(async (query: string, options) => {
654
+ const format = options.format === "json" ? "json" : "md";
655
+ const config = loadConfig();
656
+ const { retriever, profile } = createComponents(config, options.profile);
657
+ const limit = parseLimitOption(options.limit, 8, 1, 20);
658
+ const results = await retriever.retrieve(buildRetrievalContext({
659
+ query,
660
+ limit,
661
+ scope: options.scope,
662
+ sessionId: options.sessionId,
663
+ allScopes: Boolean(options.allScopes),
664
+ source: "cli",
665
+ }, {
666
+ operation: "cli:export",
667
+ }));
668
+ const summary = distillResults(results, { query, profile: profile.name });
669
+ const artifact = writeExportArtifact({
670
+ query,
671
+ profile: profile.name,
672
+ results,
673
+ summary,
674
+ format,
675
+ });
676
+
677
+ console.log([
678
+ `Exported : ${artifact.id.slice(0, 8)}`,
679
+ `Format : ${artifact.format}`,
680
+ `Profile : ${artifact.profile}`,
681
+ `Path : ${artifact.outputPath}`,
682
+ ].join("\n"));
683
+ });
684
+
685
+ const conflictsCommand = program
686
+ .command("conflicts")
687
+ .description("查看和裁决 memory promotion conflicts");
688
+
689
+ conflictsCommand
690
+ .command("list")
691
+ .description("列出近期 conflict candidates")
692
+ .option("-n, --limit <n>", "返回结果数", "20")
693
+ .option("--status <status>", "状态过滤:open / accepted-incoming / kept-existing")
694
+ .option("--attention <attention>", `生命周期过滤:${CONFLICT_ATTENTION_LEVELS.join(" / ")}`)
695
+ .option("--stale", "只看 stale / escalated 的 open conflicts")
696
+ .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤")
697
+ .option("--group-by <mode>", "输出模式:record / cluster", "record")
698
+ .option("--json", "JSON 格式输出")
699
+ .action(async (options) => {
700
+ const limit = parseLimitOption(options.limit, 20, 1, 50);
701
+ const status = parseConflictStatusOption(options.status) || (
702
+ options.attention || options.stale ? undefined : "open"
703
+ );
704
+ const attention = parseConflictAttention(options.attention);
705
+ if (options.attention && !attention) {
706
+ throw new Error(`Invalid attention level: ${options.attention}`);
707
+ }
708
+ const conflictStore = new ConflictCandidateStore();
709
+ const records = filterConflictsByAttention(await conflictStore.listRecent({
710
+ status,
711
+ canonicalKey: options.canonicalKey,
712
+ limit: Math.max(limit * 2, limit),
713
+ }), attention, Boolean(options.stale)).slice(0, limit);
714
+ const groupBy = options.groupBy === "cluster" ? "cluster" : "record";
715
+
716
+ if (options.json) {
717
+ if (groupBy === "cluster") {
718
+ const clusters = clusterConflicts(records);
719
+ console.log(JSON.stringify({
720
+ groupBy,
721
+ clusters,
722
+ count: clusters.length,
723
+ }, null, 2));
724
+ } else {
725
+ console.log(JSON.stringify({
726
+ groupBy,
727
+ conflicts: records.map((record) => ({
728
+ ...record,
729
+ lifecycle: summarizeConflictLifecycle(record),
730
+ advice: summarizeConflictAdvice(record),
731
+ })),
732
+ count: records.length,
733
+ }, null, 2));
734
+ }
735
+ return;
736
+ }
737
+
738
+ console.log(groupBy === "cluster" ? formatConflictClusters(clusterConflicts(records)) : formatConflictList(records));
739
+ });
740
+
741
+ conflictsCommand
742
+ .command("show <conflictId>")
743
+ .description("查看单条 conflict 的完整详情")
744
+ .option("--json", "JSON 格式输出")
745
+ .action(async (conflictId: string, options) => {
746
+ const conflictStore = new ConflictCandidateStore();
747
+ const record = await conflictStore.getById(conflictId);
748
+ if (!record) {
749
+ console.log(`Conflict not found: ${conflictId}`);
750
+ process.exitCode = 1;
751
+ return;
752
+ }
753
+
754
+ if (options.json) {
755
+ console.log(JSON.stringify({
756
+ ...record,
757
+ lifecycle: summarizeConflictLifecycle(record),
758
+ advice: summarizeConflictAdvice(record),
759
+ }, null, 2));
760
+ return;
761
+ }
762
+
763
+ console.log(formatConflictRecord(record));
764
+ });
765
+
766
+ conflictsCommand
767
+ .command("audit")
768
+ .description("生成一份面向终端的 conflict audit 摘要,优先指出 stale / escalated clusters")
769
+ .option("-n, --limit <n>", "扫描的 conflict 记录数", "100")
770
+ .option("--top <n>", "展示的 priority cluster 数", "5")
771
+ .option("--status <status>", "状态过滤:open / accepted-incoming / kept-existing / merged")
772
+ .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤")
773
+ .option("--export", "把 audit 报告导出到文件")
774
+ .option("--format <format>", "导出格式:md / json", "md")
775
+ .option("--output <path>", "导出文件路径;默认写到 data/exports")
776
+ .option("--json", "JSON 格式输出")
777
+ .action(async (options) => {
778
+ const limit = parseLimitOption(options.limit, 100, 1, 500);
779
+ const top = parseLimitOption(options.top, 5, 1, 20);
780
+ const status = parseConflictStatusOption(options.status);
781
+ const conflictStore = new ConflictCandidateStore();
782
+ const records = await conflictStore.listRecent({
783
+ status,
784
+ canonicalKey: options.canonicalKey,
785
+ limit,
786
+ });
787
+ const summary = buildConflictAuditSummary(records, top);
788
+ const exportRequested = Boolean(options.export || options.output);
789
+
790
+ if (options.json) {
791
+ const payload = {
792
+ generatedAt: new Date().toISOString(),
793
+ filters: {
794
+ status: status || "all",
795
+ canonicalKey: options.canonicalKey || "all",
796
+ limit,
797
+ top,
798
+ },
799
+ summary,
800
+ };
801
+ if (exportRequested) {
802
+ const format = parseExportFormat(options.format);
803
+ const outputPath = options.output
804
+ ? resolve(options.output)
805
+ : defaultConflictAuditExportPath(format, options.canonicalKey);
806
+ const rendered = format === "json"
807
+ ? JSON.stringify(payload, null, 2) + "\n"
808
+ : formatConflictAuditMarkdown(summary, {
809
+ generatedAt: payload.generatedAt,
810
+ limit,
811
+ top,
812
+ ...(status ? { status } : {}),
813
+ ...(options.canonicalKey ? { canonicalKey: options.canonicalKey } : {}),
814
+ });
815
+ writeFileSync(outputPath, rendered);
816
+ console.log(JSON.stringify({ ...payload, export: { format, outputPath } }, null, 2));
817
+ return;
818
+ }
819
+ console.log(JSON.stringify(payload, null, 2));
820
+ return;
821
+ }
822
+
823
+ const rendered = formatConflictAudit(summary);
824
+ if (!exportRequested) {
825
+ console.log(rendered);
826
+ return;
827
+ }
828
+
829
+ const format = parseExportFormat(options.format);
830
+ const generatedAt = new Date().toISOString();
831
+ const outputPath = options.output
832
+ ? resolve(options.output)
833
+ : defaultConflictAuditExportPath(format, options.canonicalKey);
834
+ const exported = format === "json"
835
+ ? JSON.stringify({
836
+ generatedAt,
837
+ filters: {
838
+ status: status || "all",
839
+ canonicalKey: options.canonicalKey || "all",
840
+ limit,
841
+ top,
842
+ },
843
+ summary,
844
+ }, null, 2) + "\n"
845
+ : formatConflictAuditMarkdown(summary, {
846
+ generatedAt,
847
+ limit,
848
+ top,
849
+ ...(status ? { status } : {}),
850
+ ...(options.canonicalKey ? { canonicalKey: options.canonicalKey } : {}),
851
+ });
852
+ writeFileSync(outputPath, exported);
853
+ console.log(`${rendered}\n\nExported: ${outputPath}`);
854
+ });
855
+
856
+ conflictsCommand
857
+ .command("escalate")
858
+ .description("预览或应用 conflict aging / escalation policy")
859
+ .option("--attention <attention>", "仅处理 stale / escalated", "stale")
860
+ .option("-k, --canonical-key <canonicalKey>", "按 canonicalKey 过滤")
861
+ .option("-n, --limit <n>", "扫描的 open conflict 数", "100")
862
+ .option("--top <n>", "最多处理多少条 eligible conflict", "10")
863
+ .option("--apply", "真正写回 escalation metadata;默认只预览")
864
+ .option("--notes <notes>", "写回时附带备注")
865
+ .option("--json", "JSON 格式输出")
866
+ .action(async (options) => {
867
+ const attention = options.attention === "escalated"
868
+ ? "escalated"
869
+ : options.attention === "stale"
870
+ ? "stale"
871
+ : null;
872
+ if (!attention) {
873
+ throw new Error("Invalid escalation attention. Use stale or escalated.");
874
+ }
875
+
876
+ const conflictStore = new ConflictCandidateStore();
877
+ const result = await escalateConflicts({
878
+ conflictStore,
879
+ }, {
880
+ attention,
881
+ canonicalKey: options.canonicalKey,
882
+ limit: parseLimitOption(options.limit, 100, 1, 500),
883
+ top: parseLimitOption(options.top, 10, 1, 20),
884
+ apply: Boolean(options.apply),
885
+ notes: options.notes,
886
+ });
887
+
888
+ if (options.json) {
889
+ console.log(JSON.stringify(result, null, 2));
890
+ return;
891
+ }
892
+
893
+ console.log(formatConflictEscalation(result));
894
+ });
895
+
896
+ conflictsCommand
897
+ .command("resolve [conflictId]")
898
+ .description("解决一条或一批 conflict candidates")
899
+ .option("--keep-existing", "保留现有 durable memory")
900
+ .option("--accept-incoming", "接受 incoming promoted text")
901
+ .option("--merge", "合并 existing 与 incoming wording")
902
+ .option("--merged-text <text>", "merge 时可选覆盖默认 merge suggestion 的文本")
903
+ .option("--all", "批量处理符合过滤条件的 conflicts")
904
+ .option("--status <status>", "批量过滤状态:open / accepted-incoming / kept-existing / merged", "open")
905
+ .option("--attention <attention>", `批量过滤生命周期:${CONFLICT_ATTENTION_LEVELS.join(" / ")}`)
906
+ .option("--stale", "批量只处理 stale / escalated 的 open conflicts")
907
+ .option("-k, --canonical-key <canonicalKey>", "批量时按 canonicalKey 过滤")
908
+ .option("-n, --limit <n>", "批量处理上限", "20")
909
+ .option("--notes <notes>", "可选备注")
910
+ .option("--json", "JSON 格式输出")
911
+ .action(async (conflictId: string | undefined, options) => {
912
+ const keepExisting = Boolean(options.keepExisting);
913
+ const acceptIncoming = Boolean(options.acceptIncoming);
914
+ const merge = Boolean(options.merge);
915
+ const selectedCount = [keepExisting, acceptIncoming, merge].filter(Boolean).length;
916
+ if (selectedCount !== 1) {
917
+ throw new Error("Choose exactly one of --keep-existing, --accept-incoming, or --merge");
918
+ }
919
+ if (options.all && conflictId) {
920
+ throw new Error("Use either a conflictId or --all, not both");
921
+ }
922
+ if (!options.all && !conflictId) {
923
+ throw new Error("Provide a conflictId or use --all");
924
+ }
925
+ if (options.mergedText && !merge) {
926
+ throw new Error("--merged-text can only be used with --merge");
927
+ }
928
+ if (options.all && options.mergedText) {
929
+ throw new Error("--merged-text is only supported when resolving a single conflict");
930
+ }
931
+
932
+ const resolution = keepExisting
933
+ ? "keep_existing"
934
+ : acceptIncoming
935
+ ? "accept_incoming"
936
+ : "merge";
937
+ const config = loadConfig();
938
+ const { store, embedder } = createComponents(config);
939
+ const conflictStore = new ConflictCandidateStore();
940
+ const deps = { store, embedder, conflictStore };
941
+
942
+ if (options.all) {
943
+ const limit = parseLimitOption(options.limit, 20, 1, 100);
944
+ const status = parseConflictStatusOption(options.status) || "open";
945
+ const attention = parseConflictAttention(options.attention);
946
+ if (options.attention && !attention) {
947
+ throw new Error(`Invalid attention level: ${options.attention}`);
948
+ }
949
+ const records = filterConflictsByAttention(await conflictStore.listRecent({
950
+ status,
951
+ canonicalKey: options.canonicalKey,
952
+ limit: Math.max(limit * 2, limit),
953
+ }), attention, Boolean(options.stale)).slice(0, limit);
954
+
955
+ const results: Array<{ conflictId: string; status?: string; error?: string }> = [];
956
+ for (const record of records) {
957
+ try {
958
+ const resolved = await resolveConflictCandidate(deps, {
959
+ conflictId: record.conflictId,
960
+ resolution,
961
+ notes: options.notes,
962
+ });
963
+ results.push({ conflictId: resolved.conflictId, status: resolved.status });
964
+ } catch (error) {
965
+ results.push({ conflictId: record.conflictId, error: errorMessage(error) });
966
+ }
967
+ }
968
+
969
+ if (options.json) {
970
+ console.log(JSON.stringify({
971
+ total: records.length,
972
+ resolved: results.filter((item) => item.status).length,
973
+ failed: results.filter((item) => item.error).length,
974
+ results,
975
+ }, null, 2));
976
+ return;
977
+ }
978
+
979
+ const lines = [
980
+ `Batch resolve: ${records.length} conflict(s)`,
981
+ `Resolution : ${resolution}`,
982
+ ];
983
+ if (options.canonicalKey) {
984
+ lines.push(`Canonical : ${options.canonicalKey}`);
985
+ }
986
+ lines.push("");
987
+ for (const item of results) {
988
+ lines.push(
989
+ item.error
990
+ ? `${item.conflictId.slice(0, 8)} ERROR ${item.error}`
991
+ : `${item.conflictId.slice(0, 8)} ${item.status}`,
992
+ );
993
+ }
994
+ console.log(lines.join("\n"));
995
+ return;
996
+ }
997
+
998
+ const result = await resolveConflictCandidate(deps, {
999
+ conflictId,
1000
+ resolution,
1001
+ ...(options.mergedText ? { mergedText: options.mergedText } : {}),
1002
+ notes: options.notes,
1003
+ });
1004
+
1005
+ if (options.json) {
1006
+ console.log(JSON.stringify(result, null, 2));
1007
+ return;
1008
+ }
1009
+
1010
+ console.log(formatConflictResolution(result));
1011
+ });
1012
+
1013
+ program
1014
+ .command("seed-patterns [file]")
1015
+ .description("批量写入 continuity / workflow pattern seeds")
1016
+ .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)")
1017
+ .option("--source <source>", "默认 source(manual / agent / api)", "agent")
1018
+ .option("--json", "JSON 输出")
1019
+ .action(async (file: string | undefined, options) => {
1020
+ const path = workflowPatternSeedsPath(file);
1021
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
1022
+ if (!Array.isArray(raw)) {
1023
+ throw new Error(`Pattern seeds file must be a JSON array: ${path}`);
1024
+ }
1025
+
1026
+ const config = loadConfig();
1027
+ const { store, embedder } = createComponents(config);
1028
+ const conflictStore = new ConflictCandidateStore();
1029
+ const existing = await store.list(undefined, "patterns", 1000, 0);
1030
+ const existingKeys = new Set(
1031
+ existing
1032
+ .map((entry) => {
1033
+ const title = extractWorkflowPatternTitle(entry);
1034
+ return title ? workflowPatternIdentity(title, entry.scope) : "";
1035
+ })
1036
+ .filter(Boolean),
1037
+ );
1038
+
1039
+ const stored: Array<{ title: string; id: string; scope: string }> = [];
1040
+ const skipped: Array<{ title: string; scope: string }> = [];
1041
+
1042
+ for (const seed of raw) {
1043
+ const input = normalizeWorkflowPatternSeed(seed, {
1044
+ scope: options.scope,
1045
+ source: options.source,
1046
+ });
1047
+ const scope = input.scope;
1048
+ const key = workflowPatternIdentity(input.title, scope);
1049
+ if (existingKeys.has(key)) {
1050
+ skipped.push({ title: input.title, scope });
1051
+ continue;
1052
+ }
1053
+
1054
+ const record = await persistWorkflowPattern({ store, embedder, conflictStore }, input);
1055
+ existingKeys.add(key);
1056
+ stored.push({ title: record.title, id: record.id, scope: record.resolvedScope });
1057
+ }
1058
+
1059
+ if (options.json) {
1060
+ console.log(JSON.stringify({ path, stored, skipped }, null, 2));
1061
+ return;
1062
+ }
1063
+
1064
+ const lines = [
1065
+ `Pattern seeds: ${path}`,
1066
+ `Stored : ${stored.length}`,
1067
+ `Skipped : ${skipped.length}`,
1068
+ ];
1069
+
1070
+ if (stored.length > 0) {
1071
+ lines.push("");
1072
+ lines.push("Stored patterns:");
1073
+ for (const item of stored) {
1074
+ lines.push(` ${item.id.slice(0, 8)} ${item.scope} ${item.title}`);
1075
+ }
1076
+ }
1077
+
1078
+ if (skipped.length > 0) {
1079
+ lines.push("");
1080
+ lines.push("Skipped existing:");
1081
+ for (const item of skipped) {
1082
+ lines.push(` ${item.scope} ${item.title}`);
1083
+ }
1084
+ }
1085
+
1086
+ console.log(lines.join("\n"));
1087
+ });
1088
+
1089
+ program
1090
+ .command("seed-cases [file]")
1091
+ .description("批量写入 continuity / reusable case seeds")
1092
+ .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)")
1093
+ .option("--source <source>", "默认 source(manual / agent / api)", "agent")
1094
+ .option("--json", "JSON 输出")
1095
+ .action(async (file: string | undefined, options) => {
1096
+ const path = caseMemorySeedsPath(file);
1097
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
1098
+ if (!Array.isArray(raw)) {
1099
+ throw new Error(`Case seeds file must be a JSON array: ${path}`);
1100
+ }
1101
+
1102
+ const config = loadConfig();
1103
+ const { store, embedder } = createComponents(config);
1104
+ const conflictStore = new ConflictCandidateStore();
1105
+ const existing = await store.list(undefined, "cases", 1000, 0);
1106
+ const existingKeys = new Set(
1107
+ existing
1108
+ .map((entry) => {
1109
+ const title = extractCaseMemoryTitle(entry);
1110
+ return title ? caseMemoryIdentity(title, entry.scope) : "";
1111
+ })
1112
+ .filter(Boolean),
1113
+ );
1114
+
1115
+ const stored: Array<{ title: string; id: string; scope: string }> = [];
1116
+ const skipped: Array<{ title: string; scope: string }> = [];
1117
+
1118
+ for (const seed of raw) {
1119
+ const input = normalizeCaseMemorySeed(seed, {
1120
+ scope: options.scope,
1121
+ source: options.source,
1122
+ });
1123
+ const scope = input.scope;
1124
+ const key = caseMemoryIdentity(input.title, scope);
1125
+ if (existingKeys.has(key)) {
1126
+ skipped.push({ title: input.title, scope });
1127
+ continue;
1128
+ }
1129
+
1130
+ const record = await persistCaseMemory({ store, embedder, conflictStore }, input);
1131
+ existingKeys.add(key);
1132
+ stored.push({ title: record.title, id: record.id, scope: record.resolvedScope });
1133
+ }
1134
+
1135
+ if (options.json) {
1136
+ console.log(JSON.stringify({ path, stored, skipped }, null, 2));
1137
+ return;
1138
+ }
1139
+
1140
+ const lines = [
1141
+ `Case seeds: ${path}`,
1142
+ `Stored : ${stored.length}`,
1143
+ `Skipped : ${skipped.length}`,
1144
+ ];
1145
+
1146
+ if (stored.length > 0) {
1147
+ lines.push("");
1148
+ lines.push("Stored cases:");
1149
+ for (const item of stored) {
1150
+ lines.push(` ${item.id.slice(0, 8)} ${item.scope} ${item.title}`);
1151
+ }
1152
+ }
1153
+
1154
+ if (skipped.length > 0) {
1155
+ lines.push("");
1156
+ lines.push("Skipped existing:");
1157
+ for (const item of skipped) {
1158
+ lines.push(` ${item.scope} ${item.title}`);
1159
+ }
1160
+ }
1161
+
1162
+ console.log(lines.join("\n"));
1163
+ });
1164
+
1165
+ program
1166
+ .command("seed-memories [file]")
1167
+ .description("批量写入 durable project / continuity memory seeds")
1168
+ .option("-s, --scope <scope>", "默认 scope(仅覆盖 seed 里未显式提供的项)")
1169
+ .option("--source <source>", "默认 source(manual / agent / api)", "agent")
1170
+ .option("--json", "JSON 输出")
1171
+ .action(async (file: string | undefined, options) => {
1172
+ const path = memorySeedsPath(file);
1173
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
1174
+ if (!Array.isArray(raw)) {
1175
+ throw new Error(`Memory seeds file must be a JSON array: ${path}`);
1176
+ }
1177
+
1178
+ const config = loadConfig();
1179
+ const { store, embedder } = createComponents(config);
1180
+ const conflictStore = new ConflictCandidateStore();
1181
+ const existing = await store.list(undefined, undefined, 2000, 0);
1182
+ const existingKeys = new Set(
1183
+ existing
1184
+ .map((entry) => storeMemoryIdentity({
1185
+ category: entry.category,
1186
+ scope: entry.scope,
1187
+ text: entry.text,
1188
+ canonicalKey: extractStoreMemoryCanonicalKey(entry),
1189
+ }))
1190
+ .filter(Boolean),
1191
+ );
1192
+
1193
+ const stored: Array<{ category: string; id: string; scope: string; text: string }> = [];
1194
+ const skipped: Array<{ category: string; scope: string; text: string }> = [];
1195
+
1196
+ for (const seed of raw) {
1197
+ const input = normalizeStoreMemorySeed(seed, {
1198
+ scope: options.scope,
1199
+ source: options.source,
1200
+ });
1201
+ const scope = input.scope;
1202
+ const key = storeMemoryIdentity(input);
1203
+ if (existingKeys.has(key)) {
1204
+ skipped.push({ category: input.category, scope, text: input.text });
1205
+ continue;
1206
+ }
1207
+
1208
+ const record = await persistMemory({ store, embedder, conflictStore }, input);
1209
+ existingKeys.add(storeMemoryIdentity({
1210
+ category: input.category,
1211
+ scope: record.resolvedScope,
1212
+ text: input.text,
1213
+ canonicalKey: record.canonicalKey,
1214
+ }));
1215
+ stored.push({
1216
+ category: record.category,
1217
+ id: record.id,
1218
+ scope: record.resolvedScope,
1219
+ text: record.text,
1220
+ });
1221
+ }
1222
+
1223
+ if (options.json) {
1224
+ console.log(JSON.stringify({ path, stored, skipped }, null, 2));
1225
+ return;
1226
+ }
1227
+
1228
+ const lines = [
1229
+ `Memory seeds: ${path}`,
1230
+ `Stored : ${stored.length}`,
1231
+ `Skipped : ${skipped.length}`,
1232
+ ];
1233
+
1234
+ if (stored.length > 0) {
1235
+ lines.push("");
1236
+ lines.push("Stored memories:");
1237
+ for (const item of stored) {
1238
+ lines.push(` ${item.id.slice(0, 8)} ${item.scope} [${item.category}] ${seedTextPreview(item.text)}`);
1239
+ }
1240
+ }
1241
+
1242
+ if (skipped.length > 0) {
1243
+ lines.push("");
1244
+ lines.push("Skipped existing:");
1245
+ for (const item of skipped) {
1246
+ lines.push(` ${item.scope} [${item.category}] ${seedTextPreview(item.text)}`);
1247
+ }
1248
+ }
1249
+
1250
+ console.log(lines.join("\n"));
1251
+ });
1252
+
1253
+ // ─── demo ────────────────────────────────────────────────────────────────────
1254
+
1255
+ program
1256
+ .command("demo")
1257
+ .description("运行示例搜索,展示 RecallNest 工作效果")
1258
+ .action(async () => {
1259
+ const demoQueries = [
1260
+ "how to debug a failing bot",
1261
+ "telegram bridge setup",
1262
+ "memory search architecture",
1263
+ ];
1264
+
1265
+ console.log("\n RecallNest Demo\n");
1266
+ console.log(" Running sample queries to show what RecallNest can do.\n");
1267
+
1268
+ const config = loadConfig();
1269
+ const { store } = createComponents(config);
1270
+ const stats = await store.stats();
1271
+
1272
+ if (stats.totalCount === 0) {
1273
+ console.log(" Index is empty. Run `lm ingest --source all` first.\n");
1274
+ return;
1275
+ }
1276
+
1277
+ console.log(` Index: ${stats.totalCount} entries across ${Object.keys(stats.scopeCounts).length} scopes\n`);
1278
+
1279
+ const { retriever, profile } = createComponents(config);
1280
+ for (const query of demoQueries) {
1281
+ console.log(` Query: "${query}"`);
1282
+ const results = await retriever.retrieve({ query, limit: 3 });
1283
+ if (results.length === 0) {
1284
+ console.log(" (no results)\n");
1285
+ continue;
1286
+ }
1287
+ for (const r of results) {
1288
+ const preview = r.entry.text.slice(0, 80).replace(/\n/g, " ");
1289
+ console.log(` [${r.score.toFixed(2)}] ${r.entry.scope} — ${preview}...`);
1290
+ }
1291
+ console.log();
1292
+ }
1293
+
1294
+ console.log(" Try your own: lm search \"your question here\"\n");
1295
+ });
1296
+
1297
+ // ─── ingest ──────────────────────────────────────────────────────────────────
1298
+
1299
+ program
1300
+ .command("ingest")
1301
+ .description("导入对话记录到索引")
1302
+ .option("-s, --source <source>", "数据源: cc / codex / memory / desktop / all", "all")
1303
+ .option("-l, --limit <n>", "限制处理文件数(调试用)")
1304
+ .option("-v, --verbose", "详细输出")
1305
+ .option("--no-dedup", "跳过向量去重")
1306
+ .option("--no-llm", "禁用 LLM 提取(原始对话将跳过不存入,排入待处理队列)")
1307
+ .option("--recent [hours]", "快速模式:只处理最近 N 小时修改的文件(默认 2 小时),跳过 ingested-files 检查")
1308
+ .action(async (options) => {
1309
+ const config = loadConfig();
1310
+ const { store, embedder, llm } = createComponents(config);
1311
+
1312
+ const source = options.source || "all";
1313
+ const limit = options.limit
1314
+ ? parseRequiredLimitOption(options.limit, "--limit", 1, Number.MAX_SAFE_INTEGER)
1315
+ : undefined;
1316
+ const verbose = options.verbose || false;
1317
+ const noDedup = options.dedup === false; // --no-dedup
1318
+ // --recent: quick mode — only process files modified within N hours, skip ingested-files check
1319
+ const recentHours: number | undefined = options.recent !== undefined
1320
+ ? (options.recent === true ? 2 : Number(options.recent))
1321
+ : undefined;
1322
+ if (recentHours !== undefined && (isNaN(recentHours) || recentHours <= 0)) {
1323
+ console.error("❌ --recent 参数必须是正数(小时数)");
1324
+ process.exitCode = 1;
1325
+ return;
1326
+ }
1327
+ const results: any[] = [];
1328
+
1329
+ // Pre-flight: validate embedding API before processing any files
1330
+ console.log("\n🔑 验证 Embedding API...");
1331
+ const testResult = await embedder.test();
1332
+ if (!testResult.success) {
1333
+ console.error(`\n❌ Embedding API 验证失败: ${testResult.error}`);
1334
+ console.error(" → 检查 .env 中的 JINA_API_KEY 是否正确");
1335
+ console.error(" → 获取免费 key: https://jina.ai/embeddings/");
1336
+ console.error(" → 运行 lm doctor 查看完整诊断\n");
1337
+ process.exitCode = 1;
1338
+ return;
1339
+ }
1340
+ console.log(` ✅ ${config.embedding.model} (${testResult.dimensions}d)`);
1341
+
1342
+ // LLM status — respect --no-llm flag
1343
+ const effectiveLlm = options.noLlm ? null : llm;
1344
+ if (options.noLlm) {
1345
+ console.log(" ⚠️ LLM: 已通过 --no-llm 禁用,原始对话将跳过不存入");
1346
+ } else if (llm) {
1347
+ const llmTest = await llm.test();
1348
+ console.log(llmTest.success
1349
+ ? ` ✅ LLM: ${config.llm?.model} (L0 摘要 + 语义去重)`
1350
+ : ` ⚠️ LLM: ${config.llm?.model} 连接失败,原始对话将跳过不存入`);
1351
+ } else {
1352
+ console.log(" ⚠️ LLM: 未配置,原始对话将跳过不存入(配置 config.json → llm 以启用)");
1353
+ }
1354
+
1355
+ // Drain pending queue if LLM is available
1356
+ if (effectiveLlm) {
1357
+ const { drainPendingQueue } = await import("./ingest.js");
1358
+ const drained = await drainPendingQueue(store, embedder, effectiveLlm);
1359
+ if (drained.processed > 0) {
1360
+ console.log(` ♻️ Pending queue: ${drained.processed} processed, ${drained.errors} errors`);
1361
+ }
1362
+ }
1363
+
1364
+ console.log(` ${noDedup ? "⚠️ 去重已禁用 (--no-dedup)" : "✅ 两阶段去重: vector + LLM 语义判断"}`);
1365
+ if (recentHours !== undefined) {
1366
+ console.log(` ⚡ 快速模式: 只处理最近 ${recentHours} 小时修改的文件,跳过 ingested-files 检查`);
1367
+ }
1368
+ console.log();
1369
+ console.log(`🔄 开始导入记忆 (source: ${source})...\n`);
1370
+
1371
+ const ingestOpts = { limit, verbose, noDedup, llm: effectiveLlm, recentHours };
1372
+
1373
+ // CC Transcripts
1374
+ if (source === "all" || source === "cc") {
1375
+ console.log("📝 导入 Claude Code 对话...");
1376
+ const ccSource = config.sources.cc;
1377
+ if (ccSource) {
1378
+ const ccPath = resolveSourcePath(ccSource.path, "cc");
1379
+ const r = await ingestCCTranscripts(store, embedder, ccPath, ingestOpts);
1380
+ results.push(r);
1381
+ console.log(` ✅ CC: ${formatIngestSummary(r)}`);
1382
+ maybeWarnHighCCDedupRate(r);
1383
+ }
1384
+ }
1385
+
1386
+ // Codex Sessions
1387
+ if (source === "all" || source === "codex") {
1388
+ console.log("🤖 导入 Codex 对话...");
1389
+ const r = await ingestCodexSessions(store, embedder, ingestOpts);
1390
+ results.push(r);
1391
+ console.log(` ✅ Codex: ${formatIngestSummary(r)}`);
1392
+ }
1393
+
1394
+ // Gemini Sessions (JSON format under ~/.gemini/tmp/*/chats/)
1395
+ if (source === "all" || source === "gemini") {
1396
+ console.log("💎 导入 Gemini 对话...");
1397
+ const r = await ingestGeminiSessions(store, embedder, {
1398
+ limit,
1399
+ verbose,
1400
+ noDedup,
1401
+ llm,
1402
+ recentHours,
1403
+ });
1404
+ results.push(r);
1405
+ console.log(` ✅ Gemini: ${formatIngestSummary(r)}`);
1406
+ }
1407
+
1408
+ // Claude Desktop / claude.ai web conversations (CC transcript format)
1409
+ if (source === "all" || source === "desktop") {
1410
+ const desktopSource = config.sources.desktop;
1411
+ if (desktopSource) {
1412
+ const desktopPath = resolve(import.meta.dir, "..", desktopSource.path);
1413
+ if (existsSync(desktopPath)) {
1414
+ console.log("🖥️ 导入 Claude Desktop 对话...");
1415
+ const r = await ingestCCTranscripts(store, embedder, desktopPath, ingestOpts);
1416
+ results.push(r);
1417
+ console.log(` ✅ Desktop: ${formatIngestSummary(r)}`);
1418
+ maybeWarnHighCCDedupRate(r);
1419
+ } else if (source === "desktop") {
1420
+ console.log("⚠️ Desktop 目录不存在,请先运行: bun scripts/pull-claude-desktop.ts");
1421
+ }
1422
+ }
1423
+ }
1424
+
1425
+ // Memory markdown files
1426
+ if (source === "all" || source === "memory") {
1427
+ console.log("📚 导入记忆文件...");
1428
+ const memSource = config.sources.memory;
1429
+ if (memSource) {
1430
+ const memPath = resolveSourcePath(memSource.path, "memory");
1431
+ const r = await ingestMarkdownFiles(store, embedder, memPath, "memory", {
1432
+ verbose,
1433
+ noDedup,
1434
+ llm,
1435
+ recentHours,
1436
+ });
1437
+ results.push(r);
1438
+ console.log(` ✅ Memory: ${formatIngestSummary(r)}`);
1439
+ }
1440
+ }
1441
+
1442
+ // Summary
1443
+ console.log("\n📊 导入汇总:");
1444
+ let totalChunks = 0;
1445
+ let totalDeduped = 0;
1446
+ let totalErrors = 0;
1447
+ const totalDedupReasons = {
1448
+ hard: 0,
1449
+ exact: 0,
1450
+ "llm-skip": 0,
1451
+ "llm-merge": 0,
1452
+ unique: 0,
1453
+ } as IngestResult["dedupReasonCounts"];
1454
+ for (const r of results) {
1455
+ totalChunks += r.chunksIngested;
1456
+ totalDeduped += r.chunksDeduped;
1457
+ totalErrors += r.errors.length;
1458
+ for (const reason of Object.keys(totalDedupReasons) as Array<keyof typeof totalDedupReasons>) {
1459
+ totalDedupReasons[reason] += r.dedupReasonCounts[reason];
1460
+ }
1461
+ if (r.errors.length > 0 && verbose) {
1462
+ console.log(` ⚠️ ${r.source} errors:`);
1463
+ for (const e of r.errors.slice(0, 5)) {
1464
+ console.log(` ${e}`);
1465
+ }
1466
+ }
1467
+ }
1468
+ console.log(
1469
+ ` 总计: ${totalChunks} chunks 已索引, ${totalDeduped} chunks 去重 (hard:${totalDedupReasons.hard}, exact:${totalDedupReasons.exact}, llm-skip:${totalDedupReasons["llm-skip"]}, llm-merge:${totalDedupReasons["llm-merge"]}), ${totalErrors} errors`,
1470
+ );
1471
+ console.log();
1472
+ });
1473
+
1474
+ // ─── stats ───────────────────────────────────────────────────────────────────
1475
+
1476
+ program
1477
+ .command("stats")
1478
+ .description("显示索引统计")
1479
+ .action(async () => {
1480
+ const config = loadConfig();
1481
+ const { store } = createComponents(config);
1482
+
1483
+ const stats = await store.stats();
1484
+
1485
+ console.log("\n📊 记忆索引统计:\n");
1486
+ console.log(` 总条目: ${stats.totalCount}`);
1487
+ console.log();
1488
+
1489
+ console.log(" 按来源:");
1490
+ for (const [scope, count] of Object.entries(stats.scopeCounts).sort(
1491
+ (a, b) => b[1] - a[1],
1492
+ )) {
1493
+ console.log(` ${scope}: ${count}`);
1494
+ }
1495
+ console.log();
1496
+
1497
+ console.log(" 按类别:");
1498
+ for (const [cat, count] of Object.entries(stats.categoryCounts)) {
1499
+ console.log(` ${cat}: ${count}`);
1500
+ }
1501
+ console.log();
1502
+ });
1503
+
1504
+ program
1505
+ .command("scope-audit")
1506
+ .description("扫描 legacy scope 异常行(missing / empty / global)")
1507
+ .option("-n, --limit <n>", "最多显示多少条示例", "20")
1508
+ .option("--json", "JSON 格式输出")
1509
+ .action(async (options) => {
1510
+ const config = loadConfig();
1511
+ const store = createStoreOnly(config);
1512
+ const limit = parseLimitOption(options.limit, 20, 1, 200);
1513
+ const audit = await store.auditLegacyScopes(limit);
1514
+
1515
+ if (options.json) {
1516
+ console.log(JSON.stringify(audit, null, 2));
1517
+ return;
1518
+ }
1519
+
1520
+ if (audit.totalCount === 0) {
1521
+ console.log("No legacy scope anomalies found.");
1522
+ return;
1523
+ }
1524
+
1525
+ console.log("Legacy scope audit\n");
1526
+ console.log(` Total anomalies: ${audit.totalCount}`);
1527
+ console.log(` Missing scope : ${audit.counts.missing}`);
1528
+ console.log(` Empty scope : ${audit.counts.empty}`);
1529
+ console.log(` Global scope : ${audit.counts.global}`);
1530
+
1531
+ if (audit.samples.length === 0) {
1532
+ return;
1533
+ }
1534
+
1535
+ console.log("\n Sample rows:");
1536
+ console.log(" ID Kind Scope Category Date Text");
1537
+ console.log(" -------- ------- ----------- ------------ ---------- ----");
1538
+ for (const sample of audit.samples) {
1539
+ const date = new Date(sample.timestamp).toISOString().slice(0, 10);
1540
+ console.log(
1541
+ ` ${sample.id.slice(0, 8)} ${sample.kind.padEnd(7)} ${formatLegacyScope(sample.scope).padEnd(11)} ${sample.category.padEnd(12)} ${date} ${seedTextPreview(sample.text, 56)}`
1542
+ );
1543
+ }
1544
+
1545
+ console.log("\n Use this report to migrate or delete historical unscoped rows before removing compatibility shims.");
1546
+ });
1547
+
1548
+ program
1549
+ .command("scope-inventory")
1550
+ .description("跨 memories / pins / checkpoints / workflow observations 统一盘点 legacy scope 异常")
1551
+ .option("-n, --limit <n>", "每层最多显示多少条示例", "20")
1552
+ .option("--json", "JSON 格式输出")
1553
+ .action(async (options) => {
1554
+ const config = loadConfig();
1555
+ const store = createStoreOnly(config);
1556
+ const limit = parseLimitOption(options.limit, 20, 1, 200);
1557
+ const report = await collectScopeInventory({
1558
+ store,
1559
+ sampleLimit: limit,
1560
+ });
1561
+
1562
+ if (options.json) {
1563
+ console.log(JSON.stringify(report, null, 2));
1564
+ return;
1565
+ }
1566
+
1567
+ console.log(formatScopeInventoryReport(report));
1568
+ });
1569
+
1570
+ // ─── reset ───────────────────────────────────────────────────────────────────
1571
+
1572
+ program
1573
+ .command("reset")
1574
+ .description("清空索引(危险!)")
1575
+ .option("--yes", "跳过确认")
1576
+ .action(async (options) => {
1577
+ if (!options.yes) {
1578
+ console.log("⚠️ 这会清空所有已索引的记忆。确认请加 --yes");
1579
+ return;
1580
+ }
1581
+
1582
+ const config = loadConfig();
1583
+ const dbPath = resolve(import.meta.dir, "..", expandHome(config.dbPath));
1584
+
1585
+ if (existsSync(dbPath)) {
1586
+ const { rmSync } = await import("node:fs");
1587
+ rmSync(dbPath, { recursive: true });
1588
+ console.log("✅ 索引已清空。");
1589
+ } else {
1590
+ console.log("索引目录不存在,无需清空。");
1591
+ }
1592
+ });
1593
+
1594
+ // ─── export ──────────────────────────────────────────────────────────────────
1595
+
1596
+ program
1597
+ .command("export-memories")
1598
+ .description("导出最近记忆为 markdown(供 digital-clone 等下游消费)")
1599
+ .option("-d, --days <n>", "导出最近 N 天的记忆", "7")
1600
+ .option("-n, --limit <n>", "最多导出条数", "200")
1601
+ .option("-o, --output <path>", "输出文件路径(默认 stdout)")
1602
+ .option("-s, --scope <scope>", "限定来源")
1603
+ .option("--json", "输出 JSONL 格式")
1604
+ .action(async (options) => {
1605
+ const config = loadConfig();
1606
+ const { store } = createComponents(config);
1607
+ const scopeFilter = toScopeFilter(options.scope);
1608
+ const limit = parseLimitOption(options.limit, 200, 1, 1000);
1609
+ const days = parseLimitOption(options.days, 7, 1, 365);
1610
+ const cutoff = Date.now() - days * 86_400_000;
1611
+
1612
+ // List all entries, filter by time
1613
+ const entries = await store.list(scopeFilter, undefined, limit * 2, 0);
1614
+ const recent = entries
1615
+ .filter(e => e.timestamp >= cutoff)
1616
+ .slice(0, limit);
1617
+
1618
+ if (recent.length === 0) {
1619
+ console.log(`最近 ${days} 天没有新记忆。`);
1620
+ return;
1621
+ }
1622
+
1623
+ let output: string;
1624
+
1625
+ if (options.json) {
1626
+ output = recent.map(e => JSON.stringify({
1627
+ id: e.id,
1628
+ text: e.text,
1629
+ category: e.category,
1630
+ scope: e.scope,
1631
+ importance: e.importance,
1632
+ timestamp: e.timestamp,
1633
+ })).join("\n") + "\n";
1634
+ } else {
1635
+ // Markdown format: grouped by scope, sorted by time
1636
+ const grouped: Record<string, typeof recent> = {};
1637
+ for (const e of recent) {
1638
+ const key = e.scope || "<missing>";
1639
+ if (!grouped[key]) grouped[key] = [];
1640
+ grouped[key].push(e);
1641
+ }
1642
+
1643
+ const lines: string[] = [
1644
+ `# RecallNest 记忆导出`,
1645
+ ``,
1646
+ `> 导出时间: ${new Date().toISOString().slice(0, 10)}`,
1647
+ `> 范围: 最近 ${days} 天, ${recent.length} 条`,
1648
+ ``,
1649
+ ];
1650
+
1651
+ for (const [scope, entries] of Object.entries(grouped)) {
1652
+ lines.push(`## ${scope}`);
1653
+ lines.push(``);
1654
+ for (const e of entries) {
1655
+ const date = new Date(e.timestamp).toISOString().slice(0, 10);
1656
+ const cat = e.category || "other";
1657
+ lines.push(`### [${cat}] ${date}`);
1658
+ lines.push(``);
1659
+ lines.push(e.text);
1660
+ lines.push(``);
1661
+ lines.push(`---`);
1662
+ lines.push(``);
1663
+ }
1664
+ }
1665
+
1666
+ output = lines.join("\n");
1667
+ }
1668
+
1669
+ if (options.output) {
1670
+ const { writeFileSync } = require("node:fs");
1671
+ const { mkdirSync } = require("node:fs");
1672
+ const { dirname } = require("node:path");
1673
+ mkdirSync(dirname(options.output), { recursive: true });
1674
+ writeFileSync(options.output, output, "utf-8");
1675
+ console.log(`✅ 导出 ${recent.length} 条记忆 → ${options.output}`);
1676
+ } else {
1677
+ console.log(output);
1678
+ }
1679
+ });
1680
+
1681
+ // ─── doctor ──────────────────────────────────────────────────────────────────
1682
+
1683
+ program
1684
+ .command("workflow-observe")
1685
+ .description("记录一条 workflow primitive observation,作为自进化观测层的 append-only 输入")
1686
+ .argument("<workflowId>", "workflow primitive id,如 resume_context / checkpoint_session")
1687
+ .argument("<summary>", "本次 observation 的简短说明")
1688
+ .option("--outcome <outcome>", "success | failure | corrected | missed", "success")
1689
+ .option("--scope <scope>", "可选 scope,如 project:recallnest")
1690
+ .option("--source <source>", "来源标签", "manual")
1691
+ .option("--signal <signal>", "失败/纠正信号标签")
1692
+ .option("--task <task>", "相关任务")
1693
+ .option("--tags <tags>", "逗号分隔 tags")
1694
+ .option("--tools <tools>", "逗号分隔 tools")
1695
+ .option("--json", "输出 JSON")
1696
+ .action(async (workflowId, summary, options) => {
1697
+ const store = new WorkflowObservationStore();
1698
+ const record = buildWorkflowObservationRecord({
1699
+ workflowId,
1700
+ summary,
1701
+ outcome: WorkflowObservationOutcomeSchema.parse(options.outcome),
1702
+ scope: options.scope,
1703
+ source: options.source,
1704
+ signal: options.signal,
1705
+ task: options.task,
1706
+ tags: splitCsvOption(options.tags),
1707
+ tools: splitCsvOption(options.tools),
1708
+ });
1709
+ const stored = await store.save(record);
1710
+ if (options.json) {
1711
+ console.log(JSON.stringify(stored, null, 2));
1712
+ return;
1713
+ }
1714
+ console.log(formatWorkflowObservationSaved(stored));
1715
+ });
1716
+
1717
+ program
1718
+ .command("workflow-health")
1719
+ .description("查看 workflow primitive 的健康度;不带 workflowId 时输出全局 dashboard")
1720
+ .argument("[workflowId]", "可选 workflow primitive id")
1721
+ .option("--scope <scope>", "可选 scope")
1722
+ .option("--limit <n>", "dashboard 返回数量上限")
1723
+ .option("--json", "输出 JSON")
1724
+ .action(async (workflowId, options) => {
1725
+ const store = new WorkflowObservationStore();
1726
+ if (workflowId) {
1727
+ const report = await inspectWorkflowHealth(store, {
1728
+ workflowId,
1729
+ scope: options.scope,
1730
+ });
1731
+ if (options.json) {
1732
+ console.log(JSON.stringify(report, null, 2));
1733
+ return;
1734
+ }
1735
+ console.log(formatWorkflowHealthReport(report));
1736
+ return;
1737
+ }
1738
+
1739
+ const dashboard = await inspectWorkflowDashboard(store, {
1740
+ scope: options.scope,
1741
+ limit: parseLimitOption(options.limit, 10, 1, 30),
1742
+ });
1743
+ if (options.json) {
1744
+ console.log(JSON.stringify(dashboard, null, 2));
1745
+ return;
1746
+ }
1747
+ console.log(formatWorkflowHealthDashboard(dashboard, options.scope));
1748
+ });
1749
+
1750
+ program
1751
+ .command("workflow-evidence")
1752
+ .description("为某个 workflow primitive 生成 evidence pack,方便后续做规则/测试收口")
1753
+ .argument("<workflowId>", "workflow primitive id")
1754
+ .option("--scope <scope>", "可选 scope")
1755
+ .option("--limit <n>", "最近 issue observation 数量上限")
1756
+ .option("--json", "输出 JSON")
1757
+ .action(async (workflowId, options) => {
1758
+ const store = new WorkflowObservationStore();
1759
+ const pack = await buildWorkflowEvidence(store, {
1760
+ workflowId,
1761
+ scope: options.scope,
1762
+ limit: parseLimitOption(options.limit, 5, 1, 20),
1763
+ });
1764
+ if (options.json) {
1765
+ console.log(JSON.stringify(pack, null, 2));
1766
+ return;
1767
+ }
1768
+ console.log(formatWorkflowEvidencePack(pack));
1769
+ });
1770
+
1771
+ program
1772
+ .command("workflow-scope-review")
1773
+ .description("审查指定 scope(默认 global)的 workflow observations,并按 cue 预览/回填目标 scope")
1774
+ .option("--current-scope <scope>", "要审查的当前 scope", "global")
1775
+ .option("--source <source>", "可选 source 过滤,如 managed")
1776
+ .option("--workflow-id <workflowId>", "可选 workflowId 过滤,如 resume_context")
1777
+ .option("--cue <needle=scope>", "可重复;把命中的 task/summary cue 映射到目标 scope", collectRepeatedOption, [])
1778
+ .option("--neighbor-window-seconds <n>", "允许从邻近 observation 继承唯一 scope 的时间窗(秒)", "90")
1779
+ .option("--apply", "把有唯一建议 scope 的记录直接回填到文件")
1780
+ .option("--keep-global-id <id>", "可重复;把指定 manual-review 记录标记为已审阅且允许保留当前 global", collectRepeatedOption, [])
1781
+ .option("--keep-global-reason <reason>", "给 --keep-global-id 使用的审阅原因")
1782
+ .option("--json", "输出 JSON")
1783
+ .action(async (options) => {
1784
+ const cues = (options.cue as string[]).map(parseWorkflowObservationScopeCue);
1785
+ const keepGlobalIds = (options.keepGlobalId as string[]) || [];
1786
+ if (keepGlobalIds.length > 0 && typeof options.keepGlobalReason !== "string") {
1787
+ throw new Error("workflow-scope-review --keep-global-id requires --keep-global-reason.");
1788
+ }
1789
+
1790
+ const review = reviewWorkflowObservationScopes({
1791
+ currentScope: options.currentScope,
1792
+ source: options.source,
1793
+ workflowId: options.workflowId,
1794
+ cues,
1795
+ neighborWindowSeconds: parseLimitOption(options.neighborWindowSeconds, 90, 0, 3600),
1796
+ });
1797
+
1798
+ const keepGlobalResult = keepGlobalIds.length > 0
1799
+ ? keepWorkflowObservationScopesGlobal(review, {
1800
+ ids: keepGlobalIds,
1801
+ reason: options.keepGlobalReason,
1802
+ })
1803
+ : undefined;
1804
+
1805
+ if (options.apply || keepGlobalResult) {
1806
+ const result = options.apply
1807
+ ? applyWorkflowObservationScopeSuggestions(review)
1808
+ : undefined;
1809
+ const latestReview = reviewWorkflowObservationScopes({
1810
+ currentScope: options.currentScope,
1811
+ source: options.source,
1812
+ workflowId: options.workflowId,
1813
+ cues,
1814
+ neighborWindowSeconds: parseLimitOption(options.neighborWindowSeconds, 90, 0, 3600),
1815
+ });
1816
+ if (options.json) {
1817
+ console.log(JSON.stringify({ review: latestReview, result, keepGlobalResult }, null, 2));
1818
+ return;
1819
+ }
1820
+
1821
+ console.log(formatWorkflowObservationScopeReview(latestReview));
1822
+ if (keepGlobalResult) {
1823
+ console.log();
1824
+ console.log(`Reviewed keep-global : ${keepGlobalResult.reviewedCount}`);
1825
+ console.log(`Skipped keep-global : ${keepGlobalResult.skippedCount}`);
1826
+ if (keepGlobalResult.unmatchedIds.length > 0) {
1827
+ console.log(`Unmatched ids : ${keepGlobalResult.unmatchedIds.join(", ")}`);
1828
+ }
1829
+ if (keepGlobalResult.ambiguousIds.length > 0) {
1830
+ console.log(`Ambiguous ids : ${keepGlobalResult.ambiguousIds.join(", ")}`);
1831
+ }
1832
+ }
1833
+ if (options.apply) {
1834
+ console.log();
1835
+ console.log(`Applied : ${result?.updatedCount || 0}`);
1836
+ console.log(`Skipped : ${result?.skippedCount || 0}`);
1837
+ }
1838
+ return;
1839
+ }
1840
+
1841
+ if (options.json) {
1842
+ console.log(JSON.stringify(review, null, 2));
1843
+ return;
1844
+ }
1845
+ console.log(formatWorkflowObservationScopeReview(review));
1846
+ });
1847
+
1848
+ program
1849
+ .command("checkpoint-gc")
1850
+ .description("Clean up old session checkpoints: archive stale rich ones, delete expired minimal ones")
1851
+ .option("--dry-run", "Preview actions without modifying files")
1852
+ .option("--keep-per-scope <n>", "Rich checkpoints to keep per scope (default: 5)")
1853
+ .option("--archive-after-days <n>", "Archive rich checkpoints older than N days (default: 30)")
1854
+ .option("--minimal-ttl-days <n>", "Delete minimal checkpoints older than N days (default: 7)")
1855
+ .option("--archive-ttl-days <n>", "Hard-delete archived files older than N days (default: 90)")
1856
+ .action(async (options) => {
1857
+ const { SessionCheckpointStore } = await import("./session-store.js");
1858
+ const store = new SessionCheckpointStore();
1859
+ const result = store.gc({
1860
+ dryRun: Boolean(options.dryRun),
1861
+ keepPerScope: options.keepPerScope ? parseLimitOption(options.keepPerScope, 5, 1, 50) : undefined,
1862
+ archiveAfterDays: options.archiveAfterDays ? parseLimitOption(options.archiveAfterDays, 30, 1, 365) : undefined,
1863
+ minimalTtlDays: options.minimalTtlDays ? parseLimitOption(options.minimalTtlDays, 7, 1, 365) : undefined,
1864
+ archiveTtlDays: options.archiveTtlDays ? parseLimitOption(options.archiveTtlDays, 90, 1, 730) : undefined,
1865
+ });
1866
+
1867
+ console.log(options.dryRun ? "=== Checkpoint GC (dry run) ===\n" : "=== Checkpoint GC ===\n");
1868
+
1869
+ const archives = result.actions.filter((a) => a.action === "archive");
1870
+ const deletes = result.actions.filter((a) => a.action === "delete");
1871
+
1872
+ if (archives.length > 0) {
1873
+ console.log(`Archive (${archives.length}):`);
1874
+ for (const a of archives) {
1875
+ console.log(` ${a.checkpointId.slice(0, 8)} ${a.resolvedScope} ${a.updatedAt.slice(0, 10)} ${a.reason}`);
1876
+ }
1877
+ console.log();
1878
+ }
1879
+
1880
+ if (deletes.length > 0) {
1881
+ console.log(`Delete (${deletes.length}):`);
1882
+ for (const a of deletes) {
1883
+ console.log(` ${a.checkpointId.slice(0, 8)} ${a.resolvedScope} ${a.updatedAt.slice(0, 10)} ${a.reason}`);
1884
+ }
1885
+ console.log();
1886
+ }
1887
+
1888
+ console.log([
1889
+ `Kept : ${result.kept}`,
1890
+ `Archived : ${result.archived}`,
1891
+ `Deleted : ${result.deleted}`,
1892
+ `Errors : ${result.errors.length}`,
1893
+ ].join("\n"));
1894
+
1895
+ if (result.errors.length > 0) {
1896
+ console.log("\nErrors:");
1897
+ for (const e of result.errors) console.log(` ${e}`);
1898
+ }
1899
+ });
1900
+
1901
+ program
1902
+ .command("doctor")
1903
+ .description("检查安装状态和配置")
1904
+ .option("--ci", "跳过 API key 在线验证(CI 模式)")
1905
+ .action(async (options) => {
1906
+ const results = await runDoctor({ ci: options.ci });
1907
+ console.log(formatDoctorResults(results));
1908
+ const hasFailure = results.some(r => r.status === "fail");
1909
+ if (hasFailure) process.exitCode = 1;
1910
+ });
1911
+
1912
+ // Run
1913
+ loadDotEnv();
1914
+ program.parseAsync(process.argv).catch((error) => {
1915
+ console.error(`RecallNest CLI error: ${errorMessage(error)}`);
1916
+ process.exitCode = 1;
1917
+ });