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/ingest.ts ADDED
@@ -0,0 +1,1822 @@
1
+ /**
2
+ * Ingest Pipeline — 把各种来源的对话/文件转成 MemoryEntry 喂进 LanceDB
3
+ *
4
+ * 支持的数据源:
5
+ * 1. Claude Code transcript (.jsonl) — 提取 user/assistant 对话轮次
6
+ * 2. Codex sessions (.jsonl) — 提取 response_item + event_msg
7
+ * 3. Markdown 记忆文件 (.md) — 按标题分块
8
+ * 4. Gemini conversations — 暂不支持(加密 protobuf,等官方开放导出)
9
+ */
10
+
11
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from "node:fs";
12
+ import { join, basename, resolve, dirname } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import type { MemoryStore, MemoryEntry } from "./store.js";
15
+ import type { Embedder } from "./embedder.js";
16
+ import { chunkDocument, type ChunkerConfig } from "./chunker.js";
17
+ import { segmentEvents, type EventSegmenterConfig, DEFAULT_EVENT_SEGMENTER_CONFIG } from "./event-segmenter.js";
18
+ import { isProcessed, markProcessed } from "./tracker.js";
19
+ import type { LLMClient, SmartExtraction } from "./llm-client.js";
20
+ import { resolveIngestBoundary } from "./memory-boundaries.js";
21
+ import { isNoise } from "./noise-filter.js";
22
+ import {
23
+ inferReplyStylePreferenceSlot,
24
+ inferToolChoicePreferenceSlot,
25
+ parseBrandItemPreference,
26
+ samePreferenceSlot,
27
+ } from "./preference-slots.js";
28
+ import { compressToolOutput } from "./tool-output-compressor.js";
29
+
30
+ // ============================================================================
31
+ // Types
32
+ // ============================================================================
33
+
34
+ export interface IngestSource {
35
+ path: string;
36
+ glob: string;
37
+ description: string;
38
+ }
39
+
40
+ export interface IngestResult {
41
+ source: string;
42
+ filesProcessed: number;
43
+ chunksIngested: number;
44
+ chunksSkipped: number;
45
+ chunksDeduped: number;
46
+ dedupReasonCounts: DedupReasonCounts;
47
+ errors: string[];
48
+ }
49
+
50
+ interface ConversationTurn {
51
+ role: "user" | "assistant";
52
+ text: string;
53
+ timestamp: string;
54
+ sessionId: string;
55
+ }
56
+
57
+ // ============================================================================
58
+ // Dedup & L0 Summary
59
+ // ============================================================================
60
+
61
+ /** Score thresholds for two-stage dedup (borrowed from memory-lancedb-pro v1.1.0).
62
+ * - Above HARD: definitely duplicate, skip without LLM
63
+ * - Between SOFT and HARD: borderline, ask LLM if available
64
+ * - Below SOFT: definitely unique, store directly
65
+ *
66
+ * 2026-03-26: raised from 0.80/0.68 → 0.92/0.78.
67
+ * Old thresholds caused 72% data loss — temporal events, user instructions,
68
+ * and file operations on familiar topics were killed as "duplicates".
69
+ */
70
+ const DEDUP_HARD_THRESHOLD = 0.92;
71
+ const DEDUP_SOFT_THRESHOLD = 0.78;
72
+ const DEDUP_CANDIDATE_LIMIT = 5;
73
+
74
+ interface AtomicPreferenceGuardDecision {
75
+ shouldForceCreate: boolean;
76
+ matchedText?: string;
77
+ }
78
+
79
+ export type DedupReason = "hard" | "exact" | "llm-skip" | "llm-merge" | "unique";
80
+
81
+ export type DedupReasonCounts = Record<DedupReason, number>;
82
+
83
+ /** Secondary action on an existing memory during dedup (e.g., delete outdated entries). */
84
+ export interface DedupAction {
85
+ id: string;
86
+ action: "delete";
87
+ reason: string;
88
+ }
89
+
90
+ export interface DedupCheckResult {
91
+ action: "store" | "skip";
92
+ reason: DedupReason;
93
+ existingText?: string;
94
+ /** Secondary actions on other existing memories (only populated with LLM dedup). */
95
+ secondaryDeletes?: DedupAction[];
96
+ }
97
+
98
+ function createDedupReasonCounts(): DedupReasonCounts {
99
+ return {
100
+ hard: 0,
101
+ exact: 0,
102
+ "llm-skip": 0,
103
+ "llm-merge": 0,
104
+ unique: 0,
105
+ };
106
+ }
107
+
108
+ function recordDedupDecision(result: IngestResult, decision: DedupCheckResult): void {
109
+ result.dedupReasonCounts[decision.reason] += 1;
110
+ if (decision.reason !== "unique") {
111
+ result.chunksDeduped += 1;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Execute secondary delete actions from a dedup decision.
117
+ * Errors are isolated per-action to avoid cascading failures.
118
+ */
119
+ async function executeSecondaryDeletes(
120
+ store: MemoryStore,
121
+ deletes: DedupAction[],
122
+ ): Promise<number> {
123
+ let executed = 0;
124
+ for (const del of deletes) {
125
+ try {
126
+ await store.delete(del.id);
127
+ executed++;
128
+ } catch {
129
+ // Per-action error isolation: log and continue
130
+ }
131
+ }
132
+ return executed;
133
+ }
134
+
135
+ export function getDedupSkippedCount(result: IngestResult): number {
136
+ return result.dedupReasonCounts.hard
137
+ + result.dedupReasonCounts.exact
138
+ + result.dedupReasonCounts["llm-skip"];
139
+ }
140
+
141
+ export function getDedupSkipRate(result: IngestResult): number {
142
+ const skipped = getDedupSkippedCount(result);
143
+ const considered = result.chunksIngested + skipped;
144
+ if (considered === 0) return 0;
145
+ return skipped / considered;
146
+ }
147
+
148
+ export function formatDedupReasonSummary(result: IngestResult): string {
149
+ const counts = result.dedupReasonCounts;
150
+ return `hard:${counts.hard}, exact:${counts.exact}, llm-skip:${counts["llm-skip"]}, llm-merge:${counts["llm-merge"]}`;
151
+ }
152
+
153
+ /**
154
+ * Two-stage dedup: vector pre-filter + optional LLM semantic decision.
155
+ *
156
+ * Returns: "store" | "skip"
157
+ *
158
+ * Note: when the LLM says MERGE, we currently keep the new chunk instead of
159
+ * dropping it. We do not have a structured ingest-time merge path yet, and
160
+ * swallowing "same topic + new information" transcript chunks is worse for
161
+ * recall fidelity than storing an incremental near-duplicate.
162
+ */
163
+ function normalizeDedupText(value: string): string {
164
+ return value
165
+ .replace(/^\[(用户|助手)\]\s*/gm, "")
166
+ .replace(/\s+/g, " ")
167
+ .trim();
168
+ }
169
+
170
+ /**
171
+ * Detect content that carries temporal events, file operations, or explicit
172
+ * user memory instructions. These should never be hard-deduped because they
173
+ * describe *new happenings* about potentially familiar topics.
174
+ *
175
+ * Categories protected:
176
+ * 1. Date-stamped events (2026-03-25, 昨天, 今天, etc.)
177
+ * 2. File operations (paths with /, mv, cp, mkdir, 整理, 搬到, 放到)
178
+ * 3. Explicit memory instructions (记住, remember, 以后注意, 别忘了)
179
+ * 4. Feedback/corrections (不要, 别再, stop doing, don't)
180
+ */
181
+ function isTemporalOrActionContent(text: string): boolean {
182
+ // Date patterns: ISO dates, Chinese relative dates
183
+ const datePattern = /\b20\d{2}[-/]\d{2}[-/]\d{2}\b|昨天|今天|前天|上次|刚才|刚刚|本周|这周|上周/;
184
+ // File operation patterns
185
+ const fileOpPattern = /\/Users\/|~\/|\.md\b|\.json\b|mkdir|mv\s|cp\s|整理|搬到|放到|移到|复制到|保存到|写入|归档/;
186
+ // Explicit memory instructions
187
+ const memoryPattern = /记住|remember|以后注意|别忘了|下次[要记别]|务必|一定要|不要忘/i;
188
+ // Feedback/correction patterns
189
+ const feedbackPattern = /不要再|别再|stop\s+doing|don'?t\s+\w+|以后别|纠正|改掉|这次的教训/i;
190
+
191
+ return datePattern.test(text)
192
+ || fileOpPattern.test(text)
193
+ || memoryPattern.test(text)
194
+ || feedbackPattern.test(text);
195
+ }
196
+
197
+ function shouldForceCreateAtomicPreference(
198
+ incomingText: string,
199
+ existingTexts: string[],
200
+ ): AtomicPreferenceGuardDecision {
201
+ const incoming = parseBrandItemPreference(incomingText);
202
+ if (!incoming || incoming.aggregate || incoming.items.length !== 1) {
203
+ return { shouldForceCreate: false };
204
+ }
205
+
206
+ let sameBrandDifferentItem: string | undefined;
207
+
208
+ for (const existingText of existingTexts) {
209
+ const existing = parseBrandItemPreference(existingText);
210
+ if (!existing || existing.brand !== incoming.brand) continue;
211
+
212
+ if (existing.items.includes(incoming.items[0])) {
213
+ return { shouldForceCreate: false };
214
+ }
215
+
216
+ sameBrandDifferentItem = existingText;
217
+ }
218
+
219
+ return sameBrandDifferentItem
220
+ ? { shouldForceCreate: true, matchedText: sameBrandDifferentItem }
221
+ : { shouldForceCreate: false };
222
+ }
223
+
224
+ function shouldForceCreateReplyStylePreference(
225
+ incomingText: string,
226
+ existingTexts: string[],
227
+ ): AtomicPreferenceGuardDecision {
228
+ const incoming = inferReplyStylePreferenceSlot(incomingText);
229
+ if (!incoming) {
230
+ return { shouldForceCreate: false };
231
+ }
232
+
233
+ let matchedText: string | undefined;
234
+
235
+ for (const existingText of existingTexts) {
236
+ const existing = inferReplyStylePreferenceSlot(existingText);
237
+ if (!existing) continue;
238
+
239
+ if (samePreferenceSlot(existing, incoming)) {
240
+ return { shouldForceCreate: false };
241
+ }
242
+
243
+ matchedText = existingText;
244
+ }
245
+
246
+ return matchedText
247
+ ? { shouldForceCreate: true, matchedText }
248
+ : { shouldForceCreate: false };
249
+ }
250
+
251
+ function shouldForceCreateToolChoicePreference(
252
+ incomingText: string,
253
+ existingTexts: string[],
254
+ ): AtomicPreferenceGuardDecision {
255
+ const incoming = inferToolChoicePreferenceSlot(incomingText);
256
+ if (!incoming) {
257
+ return { shouldForceCreate: false };
258
+ }
259
+
260
+ let matchedText: string | undefined;
261
+
262
+ for (const existingText of existingTexts) {
263
+ const existing = inferToolChoicePreferenceSlot(existingText);
264
+ if (!existing) continue;
265
+
266
+ if (samePreferenceSlot(existing, incoming)) {
267
+ return { shouldForceCreate: false };
268
+ }
269
+
270
+ matchedText = existingText;
271
+ }
272
+
273
+ return matchedText
274
+ ? { shouldForceCreate: true, matchedText }
275
+ : { shouldForceCreate: false };
276
+ }
277
+
278
+ export async function dedupCheck(
279
+ store: MemoryStore,
280
+ vector: number[],
281
+ text: string,
282
+ llm?: LLMClient | null,
283
+ ): Promise<DedupCheckResult> {
284
+ try {
285
+ // Stage 1: vector similarity check
286
+ const results = await store.vectorSearch(vector, DEDUP_CANDIDATE_LIMIT, DEDUP_SOFT_THRESHOLD);
287
+ if (results.length === 0) {
288
+ return { action: "store", reason: "unique" }; // Clearly unique
289
+ }
290
+
291
+ const normalizedIncoming = normalizeDedupText(text);
292
+ const exact = results.find((result) => normalizeDedupText(result.entry.text) === normalizedIncoming);
293
+ if (exact) {
294
+ return { action: "skip", reason: "exact", existingText: exact.entry.text };
295
+ }
296
+
297
+ const atomicPreferenceGuard = shouldForceCreateAtomicPreference(
298
+ text,
299
+ results.map((result) => result.entry.text),
300
+ );
301
+ if (atomicPreferenceGuard.shouldForceCreate) {
302
+ return {
303
+ action: "store",
304
+ reason: "unique",
305
+ existingText: atomicPreferenceGuard.matchedText,
306
+ };
307
+ }
308
+
309
+ const replyStyleGuard = shouldForceCreateReplyStylePreference(
310
+ text,
311
+ results.map((result) => result.entry.text),
312
+ );
313
+ if (replyStyleGuard.shouldForceCreate) {
314
+ return {
315
+ action: "store",
316
+ reason: "unique",
317
+ existingText: replyStyleGuard.matchedText,
318
+ };
319
+ }
320
+
321
+ const toolChoiceGuard = shouldForceCreateToolChoicePreference(
322
+ text,
323
+ results.map((result) => result.entry.text),
324
+ );
325
+ if (toolChoiceGuard.shouldForceCreate) {
326
+ return {
327
+ action: "store",
328
+ reason: "unique",
329
+ existingText: toolChoiceGuard.matchedText,
330
+ };
331
+ }
332
+
333
+ // Guard: temporal events, file operations, and explicit user memory instructions
334
+ // bypass hard dedup — these are often about familiar topics but carry new facts.
335
+ if (isTemporalOrActionContent(text)) {
336
+ // Still allow exact dedup (handled above), but never hard-skip.
337
+ // Fall through to LLM judgment if available, else store.
338
+ const topScore = results[0].score;
339
+ const existingText = results[0].entry.text;
340
+ if (!llm) {
341
+ return { action: "store", reason: "unique", existingText };
342
+ }
343
+ // Let LLM decide (skip the hard threshold)
344
+ try {
345
+ const hasMulti = typeof (llm as any).dedupDecisionMulti === "function";
346
+ if (hasMulti) {
347
+ const candidates = results
348
+ .filter(r => r.score >= DEDUP_SOFT_THRESHOLD)
349
+ .slice(0, DEDUP_CANDIDATE_LIMIT)
350
+ .map(r => ({ id: r.entry.id, text: r.entry.text }));
351
+ const decision = await llm.dedupDecisionMulti(text, candidates);
352
+ if (decision.action === "SKIP") {
353
+ return { action: "skip", reason: "llm-skip", existingText };
354
+ }
355
+ return { action: "store", reason: decision.action === "MERGE" ? "llm-merge" : "unique", existingText };
356
+ } else {
357
+ const decision = await llm.dedupDecision(text, existingText);
358
+ if (decision.action === "SKIP") {
359
+ return { action: "skip", reason: "llm-skip", existingText };
360
+ }
361
+ return { action: "store", reason: decision.action === "MERGE" ? "llm-merge" : "unique", existingText };
362
+ }
363
+ } catch {
364
+ return { action: "store", reason: "unique", existingText };
365
+ }
366
+ }
367
+
368
+ const topScore = results[0].score;
369
+ const existingText = results[0].entry.text;
370
+
371
+ // Hard duplicate: skip without LLM
372
+ if (topScore >= DEDUP_HARD_THRESHOLD) {
373
+ return { action: "skip", reason: "hard", existingText };
374
+ }
375
+
376
+ // Borderline: ask LLM if available
377
+ if (llm) {
378
+ try {
379
+ // Use multi-candidate dedup if LLM supports it, else fall back to 1:1
380
+ const hasMulti = typeof (llm as any).dedupDecisionMulti === "function";
381
+
382
+ let primaryAction: "CREATE" | "MERGE" | "SKIP" = "CREATE";
383
+ let primaryReason = "";
384
+ const secondaryDeletes: DedupAction[] = [];
385
+
386
+ if (hasMulti) {
387
+ const candidates = results
388
+ .filter(r => r.score >= DEDUP_SOFT_THRESHOLD)
389
+ .slice(0, DEDUP_CANDIDATE_LIMIT)
390
+ .map(r => ({ id: r.entry.id, text: r.entry.text }));
391
+
392
+ const decision = await llm.dedupDecisionMulti(text, candidates);
393
+ primaryAction = decision.action;
394
+ primaryReason = decision.reason;
395
+
396
+ // Build secondary deletes from validated actions (resolve IDs at parse time)
397
+ if (decision.actions) {
398
+ for (const act of decision.actions) {
399
+ const target = candidates[act.match_index - 1];
400
+ if (target) {
401
+ secondaryDeletes.push({
402
+ id: target.id,
403
+ action: "delete",
404
+ reason: typeof act.reason === "string" ? act.reason : "",
405
+ });
406
+ }
407
+ }
408
+ }
409
+ } else {
410
+ const decision = await llm.dedupDecision(text, existingText);
411
+ primaryAction = decision.action;
412
+ primaryReason = decision.reason;
413
+ }
414
+
415
+ if (primaryAction === "SKIP") {
416
+ return {
417
+ action: "skip",
418
+ reason: "llm-skip",
419
+ existingText,
420
+ ...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
421
+ };
422
+ }
423
+ if (primaryAction === "MERGE") {
424
+ return {
425
+ action: "store",
426
+ reason: "llm-merge",
427
+ existingText,
428
+ ...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
429
+ };
430
+ }
431
+ // CREATE — still execute secondary deletes if any
432
+ if (secondaryDeletes.length > 0) {
433
+ return { action: "store", reason: "unique", secondaryDeletes };
434
+ }
435
+ } catch {
436
+ // LLM failed, fall through to store
437
+ }
438
+ }
439
+
440
+ return { action: "store", reason: "unique" };
441
+ } catch {
442
+ return { action: "store", reason: "unique" }; // Fail-open
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Extractive L0 fallback: takes the first meaningful sentence.
448
+ * Used when LLM is unavailable or fails.
449
+ */
450
+ function extractL0Fallback(text: string): string {
451
+ // Strip role prefixes
452
+ const cleaned = text.replace(/^\[(用户|助手)\]\s*/gm, "").trim();
453
+
454
+ // Split into sentences (Chinese + English punctuation)
455
+ const sentences = cleaned.split(/(?<=[。!?\.\!\?\n])\s*/);
456
+
457
+ for (const s of sentences) {
458
+ const trimmed = s.trim();
459
+ // Skip very short or boilerplate sentences
460
+ if (trimmed.length >= 15 && !/^(好的|OK|是的|嗯|谢谢|Thanks)/.test(trimmed)) {
461
+ return trimmed.slice(0, 150);
462
+ }
463
+ }
464
+
465
+ // Fallback: first 150 chars
466
+ return cleaned.slice(0, 150);
467
+ }
468
+
469
+ /** Fallback extraction result when LLM is unavailable */
470
+ function fallbackExtraction(text: string): SmartExtraction {
471
+ return {
472
+ category: "events", // Default to events (most common, safest)
473
+ l0: extractL0Fallback(text),
474
+ l1: "",
475
+ importance: 0.6,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Smart extraction for a batch of texts.
481
+ * Uses LLM 6-category extraction when available, falls back to heuristic.
482
+ * Returns: category + L0 + L1 + importance for each text.
483
+ */
484
+ async function smartExtractBatch(
485
+ texts: string[],
486
+ llm?: LLMClient | null,
487
+ ): Promise<SmartExtraction[]> {
488
+ if (!llm) {
489
+ return texts.map(fallbackExtraction);
490
+ }
491
+
492
+ try {
493
+ const llmResults = await llm.smartExtractBatch(texts);
494
+ // Fill in fallbacks for any LLM failures
495
+ return llmResults.map((r, i) => r ?? fallbackExtraction(texts[i]));
496
+ } catch {
497
+ return texts.map(fallbackExtraction);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Tier 3.1: Check if core summary generation is enabled.
503
+ */
504
+ function isCoreSummaryEnabled(): boolean {
505
+ return process.env.RECALLNEST_CORE_SUMMARY === "true";
506
+ }
507
+
508
+ /**
509
+ * Tier 3.1: Generate core summaries for a batch of texts.
510
+ * Only runs when RECALLNEST_CORE_SUMMARY=true and LLM is available.
511
+ * Returns null array when disabled (no overhead).
512
+ */
513
+ async function generateCoreSummaries(
514
+ texts: string[],
515
+ llm?: LLMClient | null,
516
+ ): Promise<(string | null)[]> {
517
+ if (!isCoreSummaryEnabled() || !llm) {
518
+ return new Array(texts.length).fill(null);
519
+ }
520
+ try {
521
+ return await llm.generateCoreSummaryBatch(texts);
522
+ } catch {
523
+ return new Array(texts.length).fill(null);
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Batch-internal cosine dedup: after extraction + embedding, remove candidates
529
+ * whose vectors are too similar (>threshold) to a higher-importance candidate
530
+ * in the same batch. This saves downstream per-entry LLM dedup calls.
531
+ *
532
+ * Returns indices of entries to KEEP (in original order).
533
+ */
534
+ export function batchInternalDedup(
535
+ vectors: number[][],
536
+ importances: number[],
537
+ threshold = 0.93,
538
+ ): number[] {
539
+ if (vectors.length <= 1) return vectors.map((_, i) => i);
540
+
541
+ // Build index pairs sorted by importance descending (break ties by order)
542
+ const order = vectors.map((_, i) => i)
543
+ .sort((a, b) => (importances[b] ?? 0.5) - (importances[a] ?? 0.5) || a - b);
544
+
545
+ const kept = new Set<number>();
546
+ const keptVectors: number[][] = [];
547
+
548
+ for (const idx of order) {
549
+ const vec = vectors[idx];
550
+ let tooSimilar = false;
551
+
552
+ for (const kv of keptVectors) {
553
+ if (cosine(vec, kv) > threshold) {
554
+ tooSimilar = true;
555
+ break;
556
+ }
557
+ }
558
+
559
+ if (!tooSimilar) {
560
+ kept.add(idx);
561
+ keptVectors.push(vec);
562
+ }
563
+ }
564
+
565
+ // Return in original order
566
+ return vectors.map((_, i) => i).filter(i => kept.has(i));
567
+ }
568
+
569
+ function cosine(a: number[], b: number[]): number {
570
+ let dot = 0, na = 0, nb = 0;
571
+ for (let i = 0; i < a.length; i++) {
572
+ dot += a[i] * b[i];
573
+ na += a[i] * a[i];
574
+ nb += b[i] * b[i];
575
+ }
576
+ const norm = Math.sqrt(na) * Math.sqrt(nb);
577
+ return norm === 0 ? 0 : dot / norm;
578
+ }
579
+
580
+ // ─── Pending extraction queue (for when LLM is unavailable) ─────────────────
581
+
582
+ const PENDING_EXTRACTION_FILE = resolve(
583
+ dirname(import.meta.url.replace("file://", "")), "..", "data", "pending-extraction.json"
584
+ );
585
+
586
+ function queueForLaterExtraction(chunks: Array<{ text: string; scope: string }>): void {
587
+ let pending: Array<{ text: string; scope: string; queuedAt: string }> = [];
588
+ try {
589
+ pending = JSON.parse(readFileSync(PENDING_EXTRACTION_FILE, "utf-8"));
590
+ } catch { /* empty or missing */ }
591
+ const now = new Date().toISOString();
592
+ for (const chunk of chunks) {
593
+ pending.push({ ...chunk, queuedAt: now });
594
+ }
595
+ writeFileSync(PENDING_EXTRACTION_FILE, JSON.stringify(pending, null, 2));
596
+ }
597
+
598
+ export async function drainPendingQueue(
599
+ store: MemoryStore,
600
+ embedder: Embedder,
601
+ llm: LLMClient,
602
+ ): Promise<{ processed: number; errors: number }> {
603
+ let pending: Array<{ text: string; scope: string; queuedAt: string }> = [];
604
+ try {
605
+ pending = JSON.parse(readFileSync(PENDING_EXTRACTION_FILE, "utf-8"));
606
+ } catch { return { processed: 0, errors: 0 }; }
607
+
608
+ if (pending.length === 0) return { processed: 0, errors: 0 };
609
+
610
+ let processed = 0, errors = 0;
611
+ for (let i = 0; i < pending.length; i += 20) {
612
+ const batch = pending.slice(i, i + 20);
613
+ const texts = batch.map(c => c.text);
614
+ const extractions = await smartExtractBatch(texts, llm);
615
+ const embeddingTexts = extractions.map(e => e.l1 || e.l0);
616
+ const vectors = await embedder.embedBatchPassage(embeddingTexts);
617
+
618
+ // Batch-internal dedup: drop near-duplicate candidates before storing
619
+ const keepIndices = batchInternalDedup(
620
+ vectors,
621
+ extractions.map(e => e.importance),
622
+ );
623
+
624
+ for (const j of keepIndices) {
625
+ try {
626
+ await store.store({
627
+ text: extractions[j].l1 || extractions[j].l0,
628
+ vector: vectors[j],
629
+ category: extractions[j].category as any,
630
+ scope: batch[j].scope,
631
+ importance: extractions[j].importance,
632
+ metadata: JSON.stringify({ source: batch[j].scope.split(":")[0], l0_abstract: extractions[j].l0, l1_overview: extractions[j].l1, l2_content: extractions[j].l1 || extractions[j].l0 }),
633
+ });
634
+ processed++;
635
+ } catch { errors++; }
636
+ }
637
+ }
638
+
639
+ // Clear the queue
640
+ writeFileSync(PENDING_EXTRACTION_FILE, "[]");
641
+ return { processed, errors };
642
+ }
643
+
644
+ /** Determine initial tier based on category and importance */
645
+ function initialTier(extraction: Pick<SmartExtraction, "category" | "importance">): "core" | "working" | "peripheral" {
646
+ // Profile and patterns are inherently important → working
647
+ if (extraction.category === "profile" || extraction.category === "patterns") return "working";
648
+ // Cases (problem→solution) are valuable → working
649
+ if (extraction.category === "cases") return "working";
650
+ // High importance → working
651
+ if (extraction.importance >= 0.8) return "working";
652
+ // Everything else → peripheral
653
+ return "peripheral";
654
+ }
655
+
656
+ function buildIngestedEntry(params: {
657
+ source: string;
658
+ scope: string;
659
+ text: string;
660
+ vector: number[];
661
+ extraction: SmartExtraction;
662
+ file: string;
663
+ sessionId?: string;
664
+ heading?: string;
665
+ /** Tier 3.1: optional core summary (≤200 chars) for token-efficient context output */
666
+ coreSummary?: string | null;
667
+ }): {
668
+ text: string;
669
+ vector: number[];
670
+ category: string;
671
+ scope: string;
672
+ importance: number;
673
+ metadata: string;
674
+ } {
675
+ const resolution = resolveIngestBoundary({
676
+ source: params.source,
677
+ scope: params.scope,
678
+ category: params.extraction.category,
679
+ });
680
+ const tier = resolution.boundary.layer === "evidence"
681
+ ? "peripheral"
682
+ : initialTier({
683
+ category: resolution.category,
684
+ importance: params.extraction.importance,
685
+ });
686
+
687
+ return {
688
+ text: params.text,
689
+ vector: params.vector,
690
+ category: resolution.category,
691
+ scope: params.scope,
692
+ importance: params.extraction.importance,
693
+ metadata: JSON.stringify({
694
+ source: params.source,
695
+ ...(params.sessionId ? { sessionId: params.sessionId } : {}),
696
+ file: params.file,
697
+ ...(params.heading ? { heading: params.heading } : {}),
698
+ l0_abstract: params.extraction.l0,
699
+ l1_overview: params.extraction.l1,
700
+ l2_content: params.text,
701
+ ...(params.coreSummary ? { core_summary: params.coreSummary } : {}),
702
+ tier,
703
+ boundary: resolution.boundary,
704
+ }),
705
+ };
706
+ }
707
+
708
+ // ============================================================================
709
+ // Helpers
710
+ // ============================================================================
711
+
712
+ function expandHome(p: string): string {
713
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
714
+ return p;
715
+ }
716
+
717
+ function matchGlob(filename: string, glob: string): boolean {
718
+ // Simple glob: *.jsonl, *.md, *.{json,md,txt}
719
+ const patterns = glob
720
+ .replace(/\{([^}]+)\}/g, (_, group) => `(${group.split(",").join("|")})`)
721
+ .replace(/\./g, "\\.")
722
+ .replace(/\*/g, ".*");
723
+ return new RegExp(`^${patterns}$`).test(filename);
724
+ }
725
+
726
+ // Chunker config for conversation text
727
+ const CONVERSATION_CHUNK_CONFIG: ChunkerConfig = {
728
+ maxChunkSize: 2000,
729
+ overlapSize: 100,
730
+ minChunkSize: 100,
731
+ semanticSplit: true,
732
+ maxLinesPerChunk: 40,
733
+ };
734
+
735
+ // Chunker config for markdown files
736
+ const MARKDOWN_CHUNK_CONFIG: ChunkerConfig = {
737
+ maxChunkSize: 1500,
738
+ overlapSize: 100,
739
+ minChunkSize: 80,
740
+ semanticSplit: true,
741
+ maxLinesPerChunk: 30,
742
+ };
743
+
744
+ // ============================================================================
745
+ // CC Transcript Parser
746
+ // ============================================================================
747
+
748
+ function parseCCTranscript(filePath: string): ConversationTurn[] {
749
+ const turns: ConversationTurn[] = [];
750
+ const content = readFileSync(filePath, "utf-8");
751
+ const lines = content.split("\n").filter((l) => l.trim());
752
+
753
+ let sessionId = "";
754
+
755
+ for (const line of lines) {
756
+ try {
757
+ const obj = JSON.parse(line);
758
+
759
+ if (!sessionId && obj.sessionId) {
760
+ sessionId = obj.sessionId;
761
+ }
762
+
763
+ if (obj.type === "user" && obj.message) {
764
+ const msg = obj.message;
765
+ let text = "";
766
+ if (typeof msg.content === "string") {
767
+ text = msg.content;
768
+ } else if (Array.isArray(msg.content)) {
769
+ // Extract text blocks, skip tool_use/tool_result/images
770
+ text = msg.content
771
+ .filter((c: any) => c.type === "text")
772
+ .map((c: any) => c.text || "")
773
+ .join("\n");
774
+ }
775
+
776
+ if (text.trim().length > 10) {
777
+ turns.push({
778
+ role: "user",
779
+ text: text.trim(),
780
+ timestamp: obj.timestamp || "",
781
+ sessionId,
782
+ });
783
+ }
784
+ }
785
+
786
+ if (obj.type === "assistant" && obj.message) {
787
+ const msg = obj.message;
788
+ let text = "";
789
+ if (typeof msg.content === "string") {
790
+ text = msg.content;
791
+ } else if (Array.isArray(msg.content)) {
792
+ text = msg.content
793
+ .filter((c: any) => c.type === "text")
794
+ .map((c: any) => c.text || "")
795
+ .join("\n");
796
+ }
797
+
798
+ // 中文 8 字符已是完整句,阈值不宜过高(v1.1.0 反馈)
799
+ if (text.trim().length > 8) {
800
+ turns.push({
801
+ role: "assistant",
802
+ text: text.trim(),
803
+ timestamp: obj.timestamp || "",
804
+ sessionId,
805
+ });
806
+ }
807
+ }
808
+ } catch {
809
+ // Skip malformed lines
810
+ }
811
+ }
812
+
813
+ return turns;
814
+ }
815
+
816
+ /**
817
+ * Group conversation turns into meaningful chunks.
818
+ * Strategy: merge adjacent user+assistant pairs into one chunk,
819
+ * so search can find the full context of a Q&A exchange.
820
+ */
821
+ /**
822
+ * Pre-filter turns before chunking: remove noise turns that would dilute
823
+ * embedding quality if stored. Applied at ingest time so noisy data never
824
+ * enters the vector index.
825
+ */
826
+ function filterNoiseTurns(turns: ConversationTurn[]): ConversationTurn[] {
827
+ return turns.filter((turn) => !isNoise(turn.text));
828
+ }
829
+
830
+ function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
831
+ text: string;
832
+ timestamp: string;
833
+ sessionId: string;
834
+ }> {
835
+ const filtered = filterNoiseTurns(turns);
836
+ const chunks: Array<{ text: string; timestamp: string; sessionId: string }> = [];
837
+
838
+ for (let i = 0; i < filtered.length; i++) {
839
+ const turn = filtered[i];
840
+
841
+ // If this is a user turn followed by an assistant turn, merge them
842
+ if (turn.role === "user" && i + 1 < filtered.length && filtered[i + 1].role === "assistant") {
843
+ const nextTurn = filtered[i + 1];
844
+ // Compress tool output noise before chunking (git boilerplate, passing tests, base64)
845
+ const merged = compressToolOutput(`[用户] ${turn.text}\n\n[助手] ${nextTurn.text}`);
846
+
847
+ // If merged text is too long, segment by events then fallback to chunker
848
+ if (merged.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
849
+ const segments = segmentEvents(merged, {
850
+ maxSegmentSize: CONVERSATION_CHUNK_CONFIG.maxChunkSize,
851
+ minSegmentSize: CONVERSATION_CHUNK_CONFIG.minChunkSize,
852
+ });
853
+ for (const seg of segments) {
854
+ // If a segment is still too long, chunk it further
855
+ if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
856
+ const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
857
+ for (const chunk of chunkResult.chunks) {
858
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
859
+ }
860
+ } else {
861
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
862
+ }
863
+ }
864
+ } else {
865
+ chunks.push({
866
+ text: merged,
867
+ timestamp: turn.timestamp,
868
+ sessionId: turn.sessionId,
869
+ });
870
+ }
871
+
872
+ i++; // Skip the assistant turn we already consumed
873
+ } else {
874
+ // Standalone turn (user without response, or orphan assistant)
875
+ const prefix = turn.role === "user" ? "[用户]" : "[助手]";
876
+ const text = compressToolOutput(`${prefix} ${turn.text}`);
877
+
878
+ if (text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
879
+ const segments = segmentEvents(text, {
880
+ maxSegmentSize: CONVERSATION_CHUNK_CONFIG.maxChunkSize,
881
+ minSegmentSize: CONVERSATION_CHUNK_CONFIG.minChunkSize,
882
+ });
883
+ for (const seg of segments) {
884
+ if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
885
+ const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
886
+ for (const chunk of chunkResult.chunks) {
887
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
888
+ }
889
+ } else {
890
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
891
+ }
892
+ }
893
+ } else {
894
+ chunks.push({
895
+ text,
896
+ timestamp: turn.timestamp,
897
+ sessionId: turn.sessionId,
898
+ });
899
+ }
900
+ }
901
+ }
902
+
903
+ return chunks;
904
+ }
905
+
906
+ // ============================================================================
907
+ // Codex Session Parser
908
+ // ============================================================================
909
+
910
+ function parseCodexSession(filePath: string): ConversationTurn[] {
911
+ const turns: ConversationTurn[] = [];
912
+ const content = readFileSync(filePath, "utf-8");
913
+ const lines = content.split("\n").filter((l) => l.trim());
914
+
915
+ let sessionId = "";
916
+
917
+ for (const line of lines) {
918
+ try {
919
+ const obj = JSON.parse(line);
920
+ const payload = obj.payload;
921
+ const timestamp = obj.timestamp || "";
922
+
923
+ if (obj.type === "session_meta" && payload?.id) {
924
+ sessionId = payload.id;
925
+ }
926
+
927
+ // response_item: contains user input and assistant output
928
+ if (obj.type === "response_item" && payload) {
929
+ const role = payload.role;
930
+ const content = payload.content;
931
+
932
+ if (Array.isArray(content)) {
933
+ for (const c of content) {
934
+ if (c.type === "input_text" && c.text && c.text.length > 10) {
935
+ // Skip system/developer prompts (usually very long instructions)
936
+ if (role === "developer" || c.text.includes("<permissions instructions>")) {
937
+ continue;
938
+ }
939
+ turns.push({
940
+ role: "user",
941
+ text: c.text.trim(),
942
+ timestamp,
943
+ sessionId,
944
+ });
945
+ }
946
+ if (c.type === "output_text" && c.text && c.text.length > 8) {
947
+ turns.push({
948
+ role: "assistant",
949
+ text: c.text.trim(),
950
+ timestamp,
951
+ sessionId,
952
+ });
953
+ }
954
+ }
955
+ }
956
+ }
957
+
958
+ // event_msg: user messages
959
+ if (obj.type === "event_msg" && payload?.type === "user_message") {
960
+ const msg = payload.message;
961
+ if (msg && typeof msg === "string" && msg.length > 10) {
962
+ // Avoid duplicates with response_item input_text
963
+ const lastTurn = turns[turns.length - 1];
964
+ if (!lastTurn || lastTurn.text !== msg.trim()) {
965
+ turns.push({
966
+ role: "user",
967
+ text: msg.trim(),
968
+ timestamp,
969
+ sessionId,
970
+ });
971
+ }
972
+ }
973
+ }
974
+ } catch {
975
+ // Skip malformed lines
976
+ }
977
+ }
978
+
979
+ return turns;
980
+ }
981
+
982
+ export async function ingestCodexSessions(
983
+ store: MemoryStore,
984
+ embedder: Embedder,
985
+ options: { limit?: number; verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null; recentHours?: number } = {},
986
+ ): Promise<IngestResult> {
987
+ const result: IngestResult = {
988
+ source: "codex",
989
+ filesProcessed: 0,
990
+ chunksIngested: 0,
991
+ chunksSkipped: 0,
992
+ chunksDeduped: 0,
993
+ dedupReasonCounts: createDedupReasonCounts(),
994
+ errors: [],
995
+ };
996
+
997
+ const baseDir = expandHome("~/.codex/sessions");
998
+ if (!existsSync(baseDir)) {
999
+ result.errors.push(`Codex sessions directory not found: ${baseDir}`);
1000
+ return result;
1001
+ }
1002
+
1003
+ const recentMode = options.recentHours !== undefined;
1004
+ const cutoffMs = recentMode ? Date.now() - options.recentHours! * 3600_000 : 0;
1005
+
1006
+ // Find all .jsonl files recursively
1007
+ const allFiles: string[] = [];
1008
+ function walk(dir: string) {
1009
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1010
+ const full = join(dir, entry.name);
1011
+ if (entry.isDirectory()) {
1012
+ walk(full);
1013
+ } else if (entry.name.endsWith(".jsonl")) {
1014
+ allFiles.push(full);
1015
+ }
1016
+ }
1017
+ }
1018
+ walk(baseDir);
1019
+ allFiles.sort();
1020
+
1021
+ // --recent: only keep files modified within N hours
1022
+ const filteredFiles = recentMode
1023
+ ? allFiles.filter((f) => { try { return statSync(f).mtimeMs >= cutoffMs; } catch { return false; } })
1024
+ : allFiles;
1025
+
1026
+ const filesToProcess = options.limit ? filteredFiles.slice(0, options.limit) : filteredFiles;
1027
+ const total = filesToProcess.length;
1028
+
1029
+ for (let fi = 0; fi < filesToProcess.length; fi++) {
1030
+ const filePath = filesToProcess[fi];
1031
+
1032
+ try {
1033
+ const stat = statSync(filePath);
1034
+ if (stat.size < 200) {
1035
+ result.chunksSkipped++;
1036
+ continue;
1037
+ }
1038
+
1039
+ // In --recent mode, skip ingested-files check — files may have new content appended
1040
+ if (!recentMode && isProcessed(filePath, stat.size, stat.mtimeMs)) {
1041
+ result.chunksSkipped++;
1042
+ continue;
1043
+ }
1044
+
1045
+ const turns = parseCodexSession(filePath);
1046
+ if (turns.length === 0) {
1047
+ result.chunksSkipped++;
1048
+ markProcessed(filePath, stat.size, 0, stat.mtimeMs);
1049
+ continue;
1050
+ }
1051
+
1052
+ const chunks = groupTurnsIntoChunks(turns);
1053
+ const texts = chunks.map((c) => c.text);
1054
+ const batchSize = 32;
1055
+ let fileChunks = 0;
1056
+
1057
+ for (let i = 0; i < texts.length; i += batchSize) {
1058
+ const batch = texts.slice(i, i + batchSize);
1059
+ const batchChunks = chunks.slice(i, i + batchSize);
1060
+
1061
+ try {
1062
+ const vectors = await embedder.embedBatchPassage(batch);
1063
+ const toStore: Array<{text: string; vector: number[]; category: string; scope: string; importance: number; metadata: string}> = [];
1064
+
1065
+ // Dedup + L0 batch
1066
+ const dedupedTexts: string[] = [];
1067
+ const dedupedVectors: number[][] = [];
1068
+ const dedupedChunks: typeof batchChunks = [];
1069
+
1070
+ for (let j = 0; j < batch.length; j++) {
1071
+ const vector = vectors[j];
1072
+ if (!vector || vector.length === 0) {
1073
+ result.chunksSkipped++;
1074
+ continue;
1075
+ }
1076
+ if (!options.noDedup) {
1077
+ const decision = await dedupCheck(store, vector, batch[j], options.llm);
1078
+ recordDedupDecision(result, decision);
1079
+ if (decision.secondaryDeletes?.length) {
1080
+ await executeSecondaryDeletes(store, decision.secondaryDeletes);
1081
+ }
1082
+ if (decision.action === "skip") continue;
1083
+ }
1084
+ dedupedTexts.push(batch[j]);
1085
+ dedupedVectors.push(vector);
1086
+ dedupedChunks.push(batchChunks[j]);
1087
+ }
1088
+
1089
+ if (dedupedTexts.length > 0) {
1090
+ if (!options.llm) {
1091
+ queueForLaterExtraction(
1092
+ dedupedChunks.map(c => ({ text: c.text, scope: `codex:${c.sessionId.slice(0, 8)}` }))
1093
+ );
1094
+ result.chunksSkipped += dedupedTexts.length;
1095
+ } else {
1096
+ const extractions = await smartExtractBatch(dedupedTexts, options.llm);
1097
+ const coreSummaries = await generateCoreSummaries(dedupedTexts, options.llm);
1098
+ for (let j = 0; j < dedupedTexts.length; j++) {
1099
+ const chunk = dedupedChunks[j];
1100
+ const ext = extractions[j];
1101
+ toStore.push(buildIngestedEntry({
1102
+ source: "codex",
1103
+ scope: `codex:${chunk.sessionId.slice(0, 8)}`,
1104
+ text: dedupedTexts[j],
1105
+ vector: dedupedVectors[j],
1106
+ extraction: ext,
1107
+ sessionId: chunk.sessionId,
1108
+ file: basename(filePath),
1109
+ coreSummary: coreSummaries[j],
1110
+ }));
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ if (toStore.length > 0) {
1116
+ await store.storeBatch(toStore);
1117
+ result.chunksIngested += toStore.length;
1118
+ fileChunks += toStore.length;
1119
+ }
1120
+ } catch (err: any) {
1121
+ result.errors.push(`Embedding batch error: ${err.message}`);
1122
+ }
1123
+ }
1124
+
1125
+ markProcessed(filePath, stat.size, fileChunks, stat.mtimeMs);
1126
+ result.filesProcessed++;
1127
+
1128
+ if ((fi + 1) % 10 === 0 || fi + 1 === total) {
1129
+ console.log(
1130
+ ` Codex: ${fi + 1}/${total} files, ${result.chunksIngested} chunks ingested`,
1131
+ );
1132
+ }
1133
+ } catch (err: any) {
1134
+ result.errors.push(`Error processing ${basename(filePath)}: ${err.message}`);
1135
+ }
1136
+ }
1137
+
1138
+ return result;
1139
+ }
1140
+
1141
+ // ============================================================================
1142
+ // Gemini Session Parser
1143
+ // ============================================================================
1144
+
1145
+ function parseGeminiSession(filePath: string): ConversationTurn[] {
1146
+ const turns: ConversationTurn[] = [];
1147
+
1148
+ try {
1149
+ const content = readFileSync(filePath, "utf-8");
1150
+ const data = JSON.parse(content);
1151
+ const sessionId = data.sessionId || basename(filePath, ".json");
1152
+ const messages = data.messages || [];
1153
+
1154
+ for (const msg of messages) {
1155
+ const type = msg.type; // "user" | "gemini" | "info"
1156
+ if (type === "info") continue; // Skip system info messages
1157
+
1158
+ let text = "";
1159
+ if (typeof msg.content === "string") {
1160
+ text = msg.content;
1161
+ } else if (Array.isArray(msg.content)) {
1162
+ text = msg.content
1163
+ .filter((c: any) => c.text)
1164
+ .map((c: any) => c.text)
1165
+ .join("\n");
1166
+ }
1167
+
1168
+ if (text.trim().length < 10) continue;
1169
+
1170
+ turns.push({
1171
+ role: type === "user" ? "user" : "assistant",
1172
+ text: text.trim(),
1173
+ timestamp: data.startTime || "",
1174
+ sessionId,
1175
+ });
1176
+ }
1177
+ } catch {
1178
+ // Skip malformed files
1179
+ }
1180
+
1181
+ return turns;
1182
+ }
1183
+
1184
+ export async function ingestGeminiSessions(
1185
+ store: MemoryStore,
1186
+ embedder: Embedder,
1187
+ options: { limit?: number; verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null; recentHours?: number } = {},
1188
+ ): Promise<IngestResult> {
1189
+ const result: IngestResult = {
1190
+ source: "gemini",
1191
+ filesProcessed: 0,
1192
+ chunksIngested: 0,
1193
+ chunksSkipped: 0,
1194
+ chunksDeduped: 0,
1195
+ dedupReasonCounts: createDedupReasonCounts(),
1196
+ errors: [],
1197
+ };
1198
+
1199
+ // Scan all project dirs under ~/.gemini/tmp/
1200
+ const baseDir = expandHome("~/.gemini/tmp");
1201
+ if (!existsSync(baseDir)) {
1202
+ result.errors.push(`Gemini tmp directory not found: ${baseDir}`);
1203
+ return result;
1204
+ }
1205
+
1206
+ const recentMode = options.recentHours !== undefined;
1207
+ const cutoffMs = recentMode ? Date.now() - options.recentHours! * 3600_000 : 0;
1208
+
1209
+ const allFiles: string[] = [];
1210
+ function walk(dir: string) {
1211
+ try {
1212
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1213
+ const full = join(dir, entry.name);
1214
+ if (entry.isDirectory()) {
1215
+ walk(full);
1216
+ } else if (entry.name.startsWith("session-") && entry.name.endsWith(".json")) {
1217
+ allFiles.push(full);
1218
+ }
1219
+ }
1220
+ } catch { /* skip permission errors */ }
1221
+ }
1222
+ walk(baseDir);
1223
+ allFiles.sort();
1224
+
1225
+ // --recent: only keep files modified within N hours
1226
+ const filteredFiles = recentMode
1227
+ ? allFiles.filter((f) => { try { return statSync(f).mtimeMs >= cutoffMs; } catch { return false; } })
1228
+ : allFiles;
1229
+
1230
+ const filesToProcess = options.limit ? filteredFiles.slice(0, options.limit) : filteredFiles;
1231
+ const total = filesToProcess.length;
1232
+
1233
+ for (let fi = 0; fi < filesToProcess.length; fi++) {
1234
+ const filePath = filesToProcess[fi];
1235
+
1236
+ try {
1237
+ const stat = statSync(filePath);
1238
+ if (stat.size < 100) {
1239
+ result.chunksSkipped++;
1240
+ continue;
1241
+ }
1242
+
1243
+ // In --recent mode, skip ingested-files check — files may have new content appended
1244
+ if (!recentMode && isProcessed(filePath, stat.size, stat.mtimeMs)) {
1245
+ result.chunksSkipped++;
1246
+ continue;
1247
+ }
1248
+
1249
+ const turns = parseGeminiSession(filePath);
1250
+ if (turns.length === 0) {
1251
+ result.chunksSkipped++;
1252
+ markProcessed(filePath, stat.size, 0, stat.mtimeMs);
1253
+ continue;
1254
+ }
1255
+
1256
+ const chunks = groupTurnsIntoChunks(turns);
1257
+ const texts = chunks.map((c) => c.text);
1258
+ const batchSize = 32;
1259
+ let fileChunks = 0;
1260
+
1261
+ for (let i = 0; i < texts.length; i += batchSize) {
1262
+ const batch = texts.slice(i, i + batchSize);
1263
+ const batchChunks = chunks.slice(i, i + batchSize);
1264
+
1265
+ try {
1266
+ const vectors = await embedder.embedBatchPassage(batch);
1267
+ const toStore: Array<{text: string; vector: number[]; category: string; scope: string; importance: number; metadata: string}> = [];
1268
+
1269
+ const dedupedTexts: string[] = [];
1270
+ const dedupedVectors: number[][] = [];
1271
+ const dedupedChunks: typeof batchChunks = [];
1272
+
1273
+ for (let j = 0; j < batch.length; j++) {
1274
+ const vector = vectors[j];
1275
+ if (!vector || vector.length === 0) {
1276
+ result.chunksSkipped++;
1277
+ continue;
1278
+ }
1279
+ if (!options.noDedup) {
1280
+ const decision = await dedupCheck(store, vector, batch[j], options.llm);
1281
+ recordDedupDecision(result, decision);
1282
+ if (decision.secondaryDeletes?.length) {
1283
+ await executeSecondaryDeletes(store, decision.secondaryDeletes);
1284
+ }
1285
+ if (decision.action === "skip") continue;
1286
+ }
1287
+ dedupedTexts.push(batch[j]);
1288
+ dedupedVectors.push(vector);
1289
+ dedupedChunks.push(batchChunks[j]);
1290
+ }
1291
+
1292
+ if (dedupedTexts.length > 0) {
1293
+ if (!options.llm) {
1294
+ queueForLaterExtraction(
1295
+ dedupedChunks.map(c => ({ text: c.text, scope: `gemini:${c.sessionId.slice(0, 8)}` }))
1296
+ );
1297
+ result.chunksSkipped += dedupedTexts.length;
1298
+ } else {
1299
+ const extractions = await smartExtractBatch(dedupedTexts, options.llm);
1300
+ const coreSummaries = await generateCoreSummaries(dedupedTexts, options.llm);
1301
+ for (let j = 0; j < dedupedTexts.length; j++) {
1302
+ const chunk = dedupedChunks[j];
1303
+ const ext = extractions[j];
1304
+ toStore.push(buildIngestedEntry({
1305
+ source: "gemini",
1306
+ scope: `gemini:${chunk.sessionId.slice(0, 8)}`,
1307
+ text: dedupedTexts[j],
1308
+ vector: dedupedVectors[j],
1309
+ extraction: ext,
1310
+ sessionId: chunk.sessionId,
1311
+ file: basename(filePath),
1312
+ coreSummary: coreSummaries[j],
1313
+ }));
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ if (toStore.length > 0) {
1319
+ await store.storeBatch(toStore);
1320
+ result.chunksIngested += toStore.length;
1321
+ fileChunks += toStore.length;
1322
+ }
1323
+ } catch (err: any) {
1324
+ result.errors.push(`Embedding batch error: ${err.message}`);
1325
+ }
1326
+ }
1327
+
1328
+ markProcessed(filePath, stat.size, fileChunks, stat.mtimeMs);
1329
+ result.filesProcessed++;
1330
+
1331
+ if ((fi + 1) % 10 === 0 || fi + 1 === total) {
1332
+ console.log(
1333
+ ` Gemini: ${fi + 1}/${total} files, ${result.chunksIngested} chunks ingested`,
1334
+ );
1335
+ }
1336
+ } catch (err: any) {
1337
+ result.errors.push(`Error processing ${basename(filePath)}: ${err.message}`);
1338
+ }
1339
+ }
1340
+
1341
+ return result;
1342
+ }
1343
+
1344
+ // ============================================================================
1345
+ // Markdown Parser
1346
+ // ============================================================================
1347
+
1348
+ /**
1349
+ * Split a markdown file by headings (## or #) into chunks.
1350
+ * Each chunk includes the heading + content under it.
1351
+ */
1352
+ function parseMarkdown(filePath: string): Array<{ text: string; heading: string }> {
1353
+ const content = readFileSync(filePath, "utf-8");
1354
+ const chunks: Array<{ text: string; heading: string }> = [];
1355
+
1356
+ // Split by headings
1357
+ const sections = content.split(/^(#{1,3}\s+.+)$/m);
1358
+
1359
+ let currentHeading = basename(filePath, ".md");
1360
+ let currentText = "";
1361
+
1362
+ for (const section of sections) {
1363
+ if (/^#{1,3}\s+/.test(section)) {
1364
+ // This is a heading — save previous section
1365
+ if (currentText.trim().length > 30) {
1366
+ pushMarkdownChunks(chunks, currentHeading, currentText.trim());
1367
+ }
1368
+ currentHeading = section.replace(/^#+\s+/, "").trim();
1369
+ currentText = "";
1370
+ } else {
1371
+ currentText += section;
1372
+ }
1373
+ }
1374
+
1375
+ // Don't forget the last section
1376
+ if (currentText.trim().length > 30) {
1377
+ pushMarkdownChunks(chunks, currentHeading, currentText.trim());
1378
+ }
1379
+
1380
+ return chunks;
1381
+ }
1382
+
1383
+ function pushMarkdownChunks(
1384
+ chunks: Array<{ text: string; heading: string }>,
1385
+ heading: string,
1386
+ text: string,
1387
+ ): void {
1388
+ const fullText = `[${heading}] ${text}`;
1389
+ if (fullText.length > MARKDOWN_CHUNK_CONFIG.maxChunkSize) {
1390
+ const result = chunkDocument(fullText, MARKDOWN_CHUNK_CONFIG);
1391
+ for (const chunk of result.chunks) {
1392
+ chunks.push({ text: chunk, heading });
1393
+ }
1394
+ } else {
1395
+ chunks.push({ text: fullText, heading });
1396
+ }
1397
+ }
1398
+
1399
+ // ============================================================================
1400
+ // Main Ingest Functions
1401
+ // ============================================================================
1402
+
1403
+ export async function ingestCCTranscripts(
1404
+ store: MemoryStore,
1405
+ embedder: Embedder,
1406
+ sourcePath: string,
1407
+ options: { limit?: number; verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null; recentHours?: number } = {},
1408
+ ): Promise<IngestResult> {
1409
+ const result: IngestResult = {
1410
+ source: "cc",
1411
+ filesProcessed: 0,
1412
+ chunksIngested: 0,
1413
+ chunksSkipped: 0,
1414
+ chunksDeduped: 0,
1415
+ dedupReasonCounts: createDedupReasonCounts(),
1416
+ errors: [],
1417
+ };
1418
+
1419
+ const dir = expandHome(sourcePath);
1420
+ if (!existsSync(dir)) {
1421
+ result.errors.push(`Directory not found: ${dir}`);
1422
+ return result;
1423
+ }
1424
+
1425
+ const recentMode = options.recentHours !== undefined;
1426
+ const cutoffMs = recentMode ? Date.now() - options.recentHours! * 3600_000 : 0;
1427
+
1428
+ let files = readdirSync(dir)
1429
+ .filter((f) => f.endsWith(".jsonl"))
1430
+ .sort();
1431
+
1432
+ // --recent: only keep files modified within N hours
1433
+ if (recentMode) {
1434
+ files = files.filter((f) => {
1435
+ try { return statSync(join(dir, f)).mtimeMs >= cutoffMs; } catch { return false; }
1436
+ });
1437
+ }
1438
+
1439
+ const filesToProcess = options.limit ? files.slice(0, options.limit) : files;
1440
+ const total = filesToProcess.length;
1441
+
1442
+ for (let fi = 0; fi < filesToProcess.length; fi++) {
1443
+ const file = filesToProcess[fi];
1444
+ const filePath = join(dir, file);
1445
+
1446
+ try {
1447
+ // Skip very small files (likely empty sessions)
1448
+ const stat = statSync(filePath);
1449
+ if (stat.size < 500) {
1450
+ result.chunksSkipped++;
1451
+ continue;
1452
+ }
1453
+
1454
+ // Skip already processed files (incremental mode)
1455
+ // In --recent mode, skip this check — files may have new content appended
1456
+ if (!recentMode && isProcessed(filePath, stat.size, stat.mtimeMs)) {
1457
+ result.chunksSkipped++;
1458
+ continue;
1459
+ }
1460
+
1461
+ const turns = parseCCTranscript(filePath);
1462
+ if (turns.length === 0) {
1463
+ result.chunksSkipped++;
1464
+ markProcessed(filePath, stat.size, 0, stat.mtimeMs);
1465
+ continue;
1466
+ }
1467
+
1468
+ const chunks = groupTurnsIntoChunks(turns);
1469
+ let fileChunks = 0;
1470
+
1471
+ // Batch embed + batch store for efficiency
1472
+ const texts = chunks.map((c) => c.text);
1473
+ const batchSize = 32;
1474
+
1475
+ for (let i = 0; i < texts.length; i += batchSize) {
1476
+ const batch = texts.slice(i, i + batchSize);
1477
+ const batchChunks = chunks.slice(i, i + batchSize);
1478
+
1479
+ try {
1480
+ const vectors = await embedder.embedBatchPassage(batch);
1481
+
1482
+ // Collect non-duplicate chunks
1483
+ const dedupedTexts: string[] = [];
1484
+ const dedupedVectors: number[][] = [];
1485
+ const dedupedChunks: typeof batchChunks = [];
1486
+
1487
+ for (let j = 0; j < batch.length; j++) {
1488
+ const vector = vectors[j];
1489
+ if (!vector || vector.length === 0) {
1490
+ result.chunksSkipped++;
1491
+ continue;
1492
+ }
1493
+
1494
+ // Two-stage dedup: vector pre-filter + optional LLM
1495
+ if (!options.noDedup) {
1496
+ const decision = await dedupCheck(store, vector, batch[j], options.llm);
1497
+ recordDedupDecision(result, decision);
1498
+ if (decision.secondaryDeletes?.length) {
1499
+ await executeSecondaryDeletes(store, decision.secondaryDeletes);
1500
+ }
1501
+ if (decision.action === "skip") continue;
1502
+ }
1503
+
1504
+ dedupedTexts.push(batch[j]);
1505
+ dedupedVectors.push(vector);
1506
+ dedupedChunks.push(batchChunks[j]);
1507
+ }
1508
+
1509
+ if (dedupedTexts.length === 0) continue;
1510
+
1511
+ // Without LLM: queue raw chunks for later extraction instead of storing garbage
1512
+ if (!options.llm) {
1513
+ queueForLaterExtraction(
1514
+ dedupedChunks.map(c => ({ text: c.text, scope: `cc:${c.sessionId.slice(0, 8)}` }))
1515
+ );
1516
+ result.chunksSkipped += dedupedTexts.length;
1517
+ continue;
1518
+ }
1519
+
1520
+ // Batch smart extraction (LLM 6-category)
1521
+ const extractions = await smartExtractBatch(dedupedTexts, options.llm);
1522
+ const coreSummaries = await generateCoreSummaries(dedupedTexts, options.llm);
1523
+
1524
+ const toStore: Array<{text: string; vector: number[]; category: string; scope: string; importance: number; metadata: string}> = [];
1525
+ for (let j = 0; j < dedupedTexts.length; j++) {
1526
+ const chunk = dedupedChunks[j];
1527
+ const ext = extractions[j];
1528
+ toStore.push(buildIngestedEntry({
1529
+ source: "cc",
1530
+ scope: `cc:${chunk.sessionId.slice(0, 8)}`,
1531
+ text: dedupedTexts[j],
1532
+ vector: dedupedVectors[j],
1533
+ extraction: ext,
1534
+ sessionId: chunk.sessionId,
1535
+ file,
1536
+ coreSummary: coreSummaries[j],
1537
+ }));
1538
+ }
1539
+
1540
+ if (toStore.length > 0) {
1541
+ await store.storeBatch(toStore);
1542
+ result.chunksIngested += toStore.length;
1543
+ fileChunks += toStore.length;
1544
+ }
1545
+ } catch (err: any) {
1546
+ result.errors.push(`Embedding batch error in ${file}: ${err.message}`);
1547
+ }
1548
+ }
1549
+
1550
+ markProcessed(filePath, stat.size, fileChunks, stat.mtimeMs);
1551
+ result.filesProcessed++;
1552
+
1553
+ // Progress
1554
+ if ((fi + 1) % 10 === 0 || fi + 1 === total) {
1555
+ console.log(
1556
+ ` CC: ${fi + 1}/${total} files, ${result.chunksIngested} chunks ingested`,
1557
+ );
1558
+ }
1559
+ } catch (err: any) {
1560
+ result.errors.push(`Error processing ${file}: ${err.message}`);
1561
+ }
1562
+ }
1563
+
1564
+ return result;
1565
+ }
1566
+
1567
+ export async function ingestMarkdownFiles(
1568
+ store: MemoryStore,
1569
+ embedder: Embedder,
1570
+ sourcePath: string,
1571
+ scope: string,
1572
+ options: { verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null; recentHours?: number } = {},
1573
+ ): Promise<IngestResult> {
1574
+ const result: IngestResult = {
1575
+ source: scope,
1576
+ filesProcessed: 0,
1577
+ chunksIngested: 0,
1578
+ chunksSkipped: 0,
1579
+ chunksDeduped: 0,
1580
+ dedupReasonCounts: createDedupReasonCounts(),
1581
+ errors: [],
1582
+ };
1583
+
1584
+ const dir = expandHome(sourcePath);
1585
+ if (!existsSync(dir)) {
1586
+ result.errors.push(`Directory not found: ${dir}`);
1587
+ return result;
1588
+ }
1589
+
1590
+ const recentMode = options.recentHours !== undefined;
1591
+ const cutoffMs = recentMode ? Date.now() - options.recentHours! * 3600_000 : 0;
1592
+
1593
+ let files = readdirSync(dir).filter((f) => f.endsWith(".md"));
1594
+
1595
+ // --recent: only keep files modified within N hours
1596
+ if (recentMode) {
1597
+ files = files.filter((f) => {
1598
+ try { return statSync(join(dir, f)).mtimeMs >= cutoffMs; } catch { return false; }
1599
+ });
1600
+ }
1601
+
1602
+ for (const file of files) {
1603
+ const filePath = join(dir, file);
1604
+
1605
+ try {
1606
+ const stat = statSync(filePath);
1607
+ // In --recent mode, skip ingested-files check — files may have new content appended
1608
+ if (!recentMode && isProcessed(filePath, stat.size, stat.mtimeMs)) {
1609
+ result.chunksSkipped++;
1610
+ continue;
1611
+ }
1612
+
1613
+ const sections = parseMarkdown(filePath);
1614
+ if (sections.length === 0) {
1615
+ result.chunksSkipped++;
1616
+ markProcessed(filePath, stat.size, 0, stat.mtimeMs);
1617
+ continue;
1618
+ }
1619
+
1620
+ const texts = sections.map((s) => s.text);
1621
+ const batchSize = 32;
1622
+ let fileChunks = 0;
1623
+
1624
+ for (let i = 0; i < texts.length; i += batchSize) {
1625
+ const batch = texts.slice(i, i + batchSize);
1626
+ const batchSections = sections.slice(i, i + batchSize);
1627
+
1628
+ try {
1629
+ const vectors = await embedder.embedBatchPassage(batch);
1630
+ const toStore: Array<{text: string; vector: number[]; category: string; scope: string; importance: number; metadata: string}> = [];
1631
+
1632
+ const dedupedTexts: string[] = [];
1633
+ const dedupedVectors: number[][] = [];
1634
+ const dedupedSections: typeof batchSections = [];
1635
+
1636
+ for (let j = 0; j < batch.length; j++) {
1637
+ const vector = vectors[j];
1638
+ if (!vector || vector.length === 0) {
1639
+ result.chunksSkipped++;
1640
+ continue;
1641
+ }
1642
+ if (!options.noDedup) {
1643
+ const decision = await dedupCheck(store, vector, batch[j], options.llm);
1644
+ recordDedupDecision(result, decision);
1645
+ if (decision.secondaryDeletes?.length) {
1646
+ await executeSecondaryDeletes(store, decision.secondaryDeletes);
1647
+ }
1648
+ if (decision.action === "skip") continue;
1649
+ }
1650
+ dedupedTexts.push(batch[j]);
1651
+ dedupedVectors.push(vector);
1652
+ dedupedSections.push(batchSections[j]);
1653
+ }
1654
+
1655
+ if (dedupedTexts.length > 0) {
1656
+ const extractions = await smartExtractBatch(dedupedTexts, options.llm);
1657
+ const coreSummaries = await generateCoreSummaries(dedupedTexts, options.llm);
1658
+ for (let j = 0; j < dedupedTexts.length; j++) {
1659
+ const ext = extractions[j];
1660
+ toStore.push(buildIngestedEntry({
1661
+ source: scope,
1662
+ scope,
1663
+ text: dedupedTexts[j],
1664
+ vector: dedupedVectors[j],
1665
+ extraction: ext,
1666
+ file,
1667
+ heading: dedupedSections[j].heading,
1668
+ coreSummary: coreSummaries[j],
1669
+ }));
1670
+ }
1671
+ }
1672
+
1673
+ if (toStore.length > 0) {
1674
+ await store.storeBatch(toStore);
1675
+ result.chunksIngested += toStore.length;
1676
+ fileChunks += toStore.length;
1677
+ }
1678
+ } catch (err: any) {
1679
+ result.errors.push(`Embedding error in ${file}: ${err.message}`);
1680
+ }
1681
+ }
1682
+
1683
+ markProcessed(filePath, stat.size, fileChunks, stat.mtimeMs);
1684
+ result.filesProcessed++;
1685
+
1686
+ if (options.verbose) {
1687
+ console.log(` ${scope}: ${file} → ${sections.length} chunks`);
1688
+ }
1689
+ } catch (err: any) {
1690
+ result.errors.push(`Error processing ${file}: ${err.message}`);
1691
+ }
1692
+ }
1693
+
1694
+ return result;
1695
+ }
1696
+
1697
+ export async function ingestGenericText(
1698
+ store: MemoryStore,
1699
+ embedder: Embedder,
1700
+ sourcePath: string,
1701
+ scope: string,
1702
+ globPattern: string,
1703
+ options: { verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null } = {},
1704
+ ): Promise<IngestResult> {
1705
+ const result: IngestResult = {
1706
+ source: scope,
1707
+ filesProcessed: 0,
1708
+ chunksIngested: 0,
1709
+ chunksSkipped: 0,
1710
+ chunksDeduped: 0,
1711
+ dedupReasonCounts: createDedupReasonCounts(),
1712
+ errors: [],
1713
+ };
1714
+
1715
+ const dir = expandHome(sourcePath);
1716
+ if (!existsSync(dir)) {
1717
+ // Not an error — the directory might not exist yet (e.g. gemini/codex)
1718
+ if (options.verbose) {
1719
+ console.log(` ${scope}: directory not found, skipping (${dir})`);
1720
+ }
1721
+ return result;
1722
+ }
1723
+
1724
+ const files = readdirSync(dir).filter((f) => matchGlob(f, globPattern));
1725
+
1726
+ for (const file of files) {
1727
+ const filePath = join(dir, file);
1728
+
1729
+ try {
1730
+ const content = readFileSync(filePath, "utf-8");
1731
+ if (content.trim().length < 50) {
1732
+ result.chunksSkipped++;
1733
+ continue;
1734
+ }
1735
+
1736
+ // For JSON files, try to extract conversation structure
1737
+ let textToChunk = content;
1738
+ if (file.endsWith(".json")) {
1739
+ try {
1740
+ const parsed = JSON.parse(content);
1741
+ // Handle common export formats
1742
+ if (Array.isArray(parsed)) {
1743
+ textToChunk = parsed
1744
+ .map((item: any) => {
1745
+ if (typeof item === "string") return item;
1746
+ if (item.content) return `[${item.role || "?"}] ${item.content}`;
1747
+ return JSON.stringify(item);
1748
+ })
1749
+ .join("\n\n");
1750
+ }
1751
+ } catch {
1752
+ // Not valid JSON, treat as plain text
1753
+ }
1754
+ }
1755
+
1756
+ const chunkResult = chunkDocument(textToChunk, CONVERSATION_CHUNK_CONFIG);
1757
+
1758
+ const texts = chunkResult.chunks;
1759
+ const batchSize = 32;
1760
+
1761
+ for (let i = 0; i < texts.length; i += batchSize) {
1762
+ const batch = texts.slice(i, i + batchSize);
1763
+
1764
+ try {
1765
+ const vectors = await embedder.embedBatchPassage(batch);
1766
+ const dedupedTexts: string[] = [];
1767
+ const dedupedVectors: number[][] = [];
1768
+
1769
+ for (let j = 0; j < batch.length; j++) {
1770
+ const vector = vectors[j];
1771
+ if (!vector || vector.length === 0) {
1772
+ result.chunksSkipped++;
1773
+ continue;
1774
+ }
1775
+ if (!options.noDedup) {
1776
+ const decision = await dedupCheck(store, vector, batch[j], options.llm);
1777
+ recordDedupDecision(result, decision);
1778
+ if (decision.secondaryDeletes?.length) {
1779
+ await executeSecondaryDeletes(store, decision.secondaryDeletes);
1780
+ }
1781
+ if (decision.action === "skip") continue;
1782
+ }
1783
+ dedupedTexts.push(batch[j]);
1784
+ dedupedVectors.push(vector);
1785
+ }
1786
+
1787
+ if (dedupedTexts.length > 0) {
1788
+ const extractions = await smartExtractBatch(dedupedTexts, options.llm);
1789
+ const coreSummaries = await generateCoreSummaries(dedupedTexts, options.llm);
1790
+ const toStore: Array<{text: string; vector: number[]; category: string; scope: string; importance: number; metadata: string}> = [];
1791
+
1792
+ for (let j = 0; j < dedupedTexts.length; j++) {
1793
+ const ext = extractions[j];
1794
+ toStore.push(buildIngestedEntry({
1795
+ source: scope,
1796
+ scope,
1797
+ text: dedupedTexts[j],
1798
+ vector: dedupedVectors[j],
1799
+ extraction: ext,
1800
+ file,
1801
+ coreSummary: coreSummaries[j],
1802
+ }));
1803
+ }
1804
+
1805
+ if (toStore.length > 0) {
1806
+ await store.storeBatch(toStore);
1807
+ result.chunksIngested += toStore.length;
1808
+ }
1809
+ }
1810
+ } catch (err: any) {
1811
+ result.errors.push(`Embedding error in ${file}: ${err.message}`);
1812
+ }
1813
+ }
1814
+
1815
+ result.filesProcessed++;
1816
+ } catch (err: any) {
1817
+ result.errors.push(`Error processing ${file}: ${err.message}`);
1818
+ }
1819
+ }
1820
+
1821
+ return result;
1822
+ }