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.
- package/.claude-plugin/CLAUDE.md +29 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.env.example +3 -0
- package/.github/workflows/ci.yml +28 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +42 -0
- package/DEMO.md +94 -0
- package/EVAL.md +89 -0
- package/FAILURES.md +75 -0
- package/LICENSE +21 -0
- package/OPERATIONS.md +59 -0
- package/README.md +427 -0
- package/README_CN.md +425 -0
- package/ROADMAP.md +198 -0
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +807 -0
- package/assets/ui/index.html +154 -0
- package/assets/ui/styles.css +605 -0
- package/assets/wechat-qr.jpg +0 -0
- package/config.json.example +38 -0
- package/daily-recall.sh +87 -0
- package/daily-reflections/2026-03-05.md +15 -0
- package/daily-reflections/2026-03-06.md +15 -0
- package/daily-reflections/2026-03-07.md +7 -0
- package/daily-reflections/2026-03-08.md +34 -0
- package/daily-reflections/2026-03-09.md +35 -0
- package/daily-reflections/2026-03-10.md +36 -0
- package/daily-reflections/2026-03-11.md +35 -0
- package/daily-reflections/2026-03-12.md +36 -0
- package/daily-reflections/2026-03-13.md +35 -0
- package/daily-reflections/2026-03-14.md +35 -0
- package/daily-reflections/2026-03-15.md +35 -0
- package/daily-reflections/2026-03-16.md +41 -0
- package/daily-reflections/2026-03-17.md +10 -0
- package/daily-reflections/2026-03-18.md +10 -0
- package/daily-reflections/2026-03-19.md +10 -0
- package/daily-reflections/2026-03-20.md +10 -0
- package/daily-reflections/2026-03-21.md +10 -0
- package/daily-reflections/2026-03-22.md +10 -0
- package/daily-reflections/2026-03-23.md +10 -0
- package/daily-reflections/2026-03-24.md +10 -0
- package/daily-reflections/2026-03-25.md +32 -0
- package/daily-reflections/2026-03-26.md +37 -0
- package/daily-reflections/2026-03-27.md +35 -0
- package/daily-reflections/2026-03-28.md +35 -0
- package/daily-reflections/2026-03-29.md +35 -0
- package/daily-reflections/2026-03-30.md +36 -0
- package/daily-reflections/2026-03-31.md +35 -0
- package/daily-reflections/2026-04-01.md +35 -0
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/memory-boundary-contract.md +226 -0
- package/docs/memory-categories.md +119 -0
- package/docs/pipeline-audit-ultramemory.md +177 -0
- package/docs/plans/2026-03-08-query-expansion.md +270 -0
- package/docs/plans/2026-03-16-continuity-layer.md +196 -0
- package/docs/plans/mcp-tiering-plan.md +126 -0
- package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
- package/docs/self-evolution.md +198 -0
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
- package/eval/cases.json +85 -0
- package/eval/continuity/case-seeds.json +90 -0
- package/eval/continuity/cases.json +302 -0
- package/eval/continuity/memory-seeds.json +26 -0
- package/eval/continuity/pattern-seeds.json +100 -0
- package/eval/continuity/results-2026-03-16.md +117 -0
- package/eval/continuity/results-2026-03-19.md +453 -0
- package/eval/continuity/runbook.md +110 -0
- package/eval/continuity/seed-cases.md +54 -0
- package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
- package/eval/reports/2026-03-06-baseline.md +82 -0
- package/eval/reports/query-expansion.md +138 -0
- package/integrations/README.md +57 -0
- package/integrations/claude-code/README.md +133 -0
- package/integrations/claude-code/claude-md-snippet.md +31 -0
- package/integrations/claude-code/setup.sh +66 -0
- package/integrations/claude-code/smoke-continuity.sh +452 -0
- package/integrations/codex/README.md +49 -0
- package/integrations/codex/agents-md-snippet.md +31 -0
- package/integrations/codex/setup.sh +58 -0
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
- package/integrations/examples/langchain/memory-chain.py +111 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
- package/integrations/gemini-cli/README.md +57 -0
- package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
- package/integrations/gemini-cli/setup.sh +64 -0
- package/integrations/shared/install-managed-block.sh +51 -0
- package/lm +19 -0
- package/package.json +50 -0
- package/plan-v1.2-distributable.md +105 -0
- package/scripts/archive-distilled-junk.ts +116 -0
- package/scripts/backfill-l0l1l2.ts +98 -0
- package/scripts/blind-scan.ts +588 -0
- package/scripts/cleanup-step1-exact-dups.ts +117 -0
- package/scripts/cleanup-step2-template-dups.ts +113 -0
- package/scripts/cleanup-step3-stale-content.ts +149 -0
- package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
- package/scripts/delete-distilled-junk.ts +88 -0
- package/scripts/distill-facts.ts +305 -0
- package/scripts/health-check.ts +343 -0
- package/scripts/incremental-ingest.sh +71 -0
- package/scripts/mlp-health-check.ts +382 -0
- package/scripts/pull-claude-desktop.ts +515 -0
- package/scripts/re-ingest-cc.sh +66 -0
- package/scripts/rebalance-memory-health.ts +181 -0
- package/scripts/run-desktop-ingest.sh +11 -0
- package/scripts/start-server.sh +12 -0
- package/scripts/viz-dashboard.html +423 -0
- package/scripts/viz-export.ts +703 -0
- package/src/__tests__/access-tracker-novelty.test.ts +228 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/capture-engine.test.ts +1142 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- package/src/__tests__/confidence-tracker.test.ts +196 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/consolidation-engine.test.ts +181 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/doctor.test.ts +303 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/kg-extractor.test.ts +184 -0
- package/src/__tests__/kg-store.test.ts +198 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/memory-boundaries.test.ts +237 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +106 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4743 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/session-checkpoint.test.ts +259 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-output-compressor.test.ts +133 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation.test.ts +400 -0
- package/src/access-tracker.ts +316 -0
- package/src/adaptive-retrieval.ts +64 -0
- package/src/api-server.ts +667 -0
- package/src/asset-sync.ts +79 -0
- package/src/auto-consolidation.ts +133 -0
- package/src/auto-gc.ts +124 -0
- package/src/auto-recall.ts +95 -0
- package/src/capture-engine.ts +931 -0
- package/src/capture-heuristic.ts +177 -0
- package/src/cascade-forget.ts +135 -0
- package/src/chunker.ts +253 -0
- package/src/cli.ts +1917 -0
- package/src/confidence-tracker.ts +166 -0
- package/src/conflict-advisor.ts +433 -0
- package/src/conflict-engine.ts +181 -0
- package/src/conflict-escalation.ts +138 -0
- package/src/conflict-lifecycle.ts +67 -0
- package/src/conflict-output.ts +218 -0
- package/src/conflict-schema.ts +130 -0
- package/src/conflict-store.ts +127 -0
- package/src/consolidation-engine.ts +256 -0
- package/src/context-composer-pins.ts +183 -0
- package/src/context-composer-queries.ts +121 -0
- package/src/context-composer-scope.ts +56 -0
- package/src/context-composer-stable-selection.ts +200 -0
- package/src/context-composer-stable.ts +217 -0
- package/src/context-composer-task-fallbacks.ts +36 -0
- package/src/context-composer-task-ranking.ts +197 -0
- package/src/context-composer-task-results.ts +137 -0
- package/src/context-composer-task-selection.ts +342 -0
- package/src/context-composer-text.ts +57 -0
- package/src/context-composer.ts +300 -0
- package/src/context-renderer.ts +179 -0
- package/src/decay-engine.ts +189 -0
- package/src/doctor.ts +543 -0
- package/src/embedder.ts +557 -0
- package/src/entity-resolver.ts +131 -0
- package/src/eval.ts +632 -0
- package/src/event-segmenter.ts +216 -0
- package/src/ingest.ts +1822 -0
- package/src/kg-extractor.ts +217 -0
- package/src/kg-store.ts +353 -0
- package/src/legacy-scope-review.ts +53 -0
- package/src/llm-client.ts +629 -0
- package/src/llm-consolidation.ts +168 -0
- package/src/mcp-server.ts +1337 -0
- package/src/memory-assets.ts +475 -0
- package/src/memory-boundaries.ts +354 -0
- package/src/memory-health-rebalance.ts +220 -0
- package/src/memory-output.ts +474 -0
- package/src/memory-schema.ts +184 -0
- package/src/multi-vector.ts +234 -0
- package/src/noise-filter.ts +155 -0
- package/src/ppr-traversal.ts +279 -0
- package/src/preference-matcher.ts +174 -0
- package/src/preference-slots.ts +259 -0
- package/src/prospective-memory.ts +198 -0
- package/src/query-entity-detector.ts +207 -0
- package/src/query-expander.ts +98 -0
- package/src/result-synthesizer.ts +114 -0
- package/src/retrieval-profiles.ts +101 -0
- package/src/retrieval-trace.ts +94 -0
- package/src/retriever.ts +1338 -0
- package/src/retroactive-boost.ts +154 -0
- package/src/rif.ts +73 -0
- package/src/runtime-config.ts +172 -0
- package/src/schema-utils.ts +56 -0
- package/src/scope-inventory.ts +310 -0
- package/src/scope-policy.ts +142 -0
- package/src/session-engine.ts +168 -0
- package/src/session-output.ts +96 -0
- package/src/session-schema.ts +60 -0
- package/src/session-store.ts +293 -0
- package/src/stderr-log.ts +16 -0
- package/src/store.ts +903 -0
- package/src/temporal-parser.ts +318 -0
- package/src/term-registry.ts +791 -0
- package/src/tool-output-compressor.ts +115 -0
- package/src/tracker.ts +57 -0
- package/src/ui-server.ts +430 -0
- package/src/version-manager.ts +172 -0
- package/src/workflow-observation-engine.ts +316 -0
- package/src/workflow-observation-managed.ts +83 -0
- package/src/workflow-observation-output.ts +86 -0
- package/src/workflow-observation-schema.ts +87 -0
- package/src/workflow-observation-scope-review.ts +409 -0
- 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
|
+
}
|