recallnest 3.0.0 → 4.0.0
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/CHANGELOG.md +304 -0
- package/README.md +251 -258
- package/README_CN.md +226 -247
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +12 -3
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +459 -71
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer-stable.ts +5 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/embedder.ts +24 -0
- package/src/env-config.ts +136 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +294 -121
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-extractor.ts +17 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +311 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +55 -5
- package/src/pivot-evidence.ts +128 -0
- package/src/ppr-traversal.ts +25 -1
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +15 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-engine.ts +11 -4
- package/src/session-output.ts +20 -1
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +59 -12
- package/src/skill-promotion.ts +67 -1
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
- package/src/__tests__/access-tracker-novelty.test.ts +0 -324
- package/src/__tests__/activity-counter.test.ts +0 -125
- package/src/__tests__/adaptive-fidelity.test.ts +0 -102
- package/src/__tests__/admission-control.test.ts +0 -206
- package/src/__tests__/age-label.test.ts +0 -33
- package/src/__tests__/anchor-generator.test.ts +0 -67
- package/src/__tests__/api-server-security.test.ts +0 -102
- package/src/__tests__/auto-consolidation.test.ts +0 -168
- package/src/__tests__/auto-recall.test.ts +0 -131
- package/src/__tests__/babel-integration.test.ts +0 -144
- package/src/__tests__/batch-dedup.test.ts +0 -65
- package/src/__tests__/belief-history.test.ts +0 -220
- package/src/__tests__/canary-eval.test.ts +0 -114
- package/src/__tests__/capture-engine.test.ts +0 -1328
- package/src/__tests__/capture-heuristic.test.ts +0 -171
- package/src/__tests__/cascade-forget.test.ts +0 -210
- package/src/__tests__/category-min-scores.test.ts +0 -116
- package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
- package/src/__tests__/circuit-breaker.test.ts +0 -99
- package/src/__tests__/cluster-consolidation.test.ts +0 -1236
- package/src/__tests__/confidence-metadata.test.ts +0 -125
- package/src/__tests__/confidence-tracker.test.ts +0 -261
- package/src/__tests__/conflict-advisor.test.ts +0 -206
- package/src/__tests__/conflict-escalation.test.ts +0 -149
- package/src/__tests__/conflict-lifecycle.test.ts +0 -83
- package/src/__tests__/conflict-output.test.ts +0 -85
- package/src/__tests__/conflict-store.test.ts +0 -418
- package/src/__tests__/connector-v1.test.ts +0 -181
- package/src/__tests__/consolidation-engine.test.ts +0 -520
- package/src/__tests__/constructive-retrieval.test.ts +0 -509
- package/src/__tests__/context-collapse-renderer.test.ts +0 -214
- package/src/__tests__/context-composer-pins.test.ts +0 -64
- package/src/__tests__/context-composer-task-selection.test.ts +0 -24
- package/src/__tests__/context-reconstructor.test.ts +0 -65
- package/src/__tests__/context-renderer.test.ts +0 -91
- package/src/__tests__/continuity-eval.test.ts +0 -275
- package/src/__tests__/continuity-schema.test.ts +0 -97
- package/src/__tests__/contracts.test.ts +0 -262
- package/src/__tests__/conversation-importer.test.ts +0 -692
- package/src/__tests__/core-recall-eval.test.ts +0 -235
- package/src/__tests__/core-summary.test.ts +0 -109
- package/src/__tests__/data-checkup.test.ts +0 -304
- package/src/__tests__/decay-exemption.test.ts +0 -129
- package/src/__tests__/dedup-actions.test.ts +0 -58
- package/src/__tests__/dedup-guard.test.ts +0 -39
- package/src/__tests__/deterministic-id.test.ts +0 -39
- package/src/__tests__/distill-lock.test.ts +0 -305
- package/src/__tests__/distill-session-argshape.test.ts +0 -79
- package/src/__tests__/doctor.test.ts +0 -319
- package/src/__tests__/dream-pipeline.test.ts +0 -820
- package/src/__tests__/dream-scope-isolation.test.ts +0 -386
- package/src/__tests__/dream-write-batching.test.ts +0 -211
- package/src/__tests__/drill-down.test.ts +0 -108
- package/src/__tests__/embedder.test.ts +0 -122
- package/src/__tests__/emotion-decay.test.ts +0 -46
- package/src/__tests__/emotion-detector.test.ts +0 -56
- package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
- package/src/__tests__/emotion-retrieval.test.ts +0 -56
- package/src/__tests__/entity-resolver.test.ts +0 -58
- package/src/__tests__/env-config-invariant.test.ts +0 -80
- package/src/__tests__/env-config.test.ts +0 -211
- package/src/__tests__/error-signature-boost.test.ts +0 -87
- package/src/__tests__/error-signature.test.ts +0 -95
- package/src/__tests__/error-taxonomy.test.ts +0 -129
- package/src/__tests__/ethics-layer.test.ts +0 -476
- package/src/__tests__/event-segmenter.test.ts +0 -153
- package/src/__tests__/feature-flags-integration.test.ts +0 -54
- package/src/__tests__/frequency-tracker.test.ts +0 -113
- package/src/__tests__/freshness.test.ts +0 -186
- package/src/__tests__/graph-export.test.ts +0 -489
- package/src/__tests__/hotness-blend.test.ts +0 -283
- package/src/__tests__/ingest-chunking.test.ts +0 -44
- package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
- package/src/__tests__/ingest.test.ts +0 -360
- package/src/__tests__/interference-detector.test.ts +0 -203
- package/src/__tests__/kg-extractor.test.ts +0 -192
- package/src/__tests__/kg-store.test.ts +0 -300
- package/src/__tests__/llm-client-pivot.test.ts +0 -628
- package/src/__tests__/llm-consolidation.test.ts +0 -226
- package/src/__tests__/maintenance-locks.test.ts +0 -197
- package/src/__tests__/mcp-efficiency.test.ts +0 -288
- package/src/__tests__/mcp-related-scopes.test.ts +0 -151
- package/src/__tests__/memory-boundaries.test.ts +0 -282
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
- package/src/__tests__/memory-evolution.test.ts +0 -271
- package/src/__tests__/memory-health-rebalance.test.ts +0 -144
- package/src/__tests__/memory-lint.test.ts +0 -421
- package/src/__tests__/memory-output.test.ts +0 -224
- package/src/__tests__/memory-promotion.test.ts +0 -315
- package/src/__tests__/memory-utility.test.ts +0 -256
- package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
- package/src/__tests__/multi-vector.test.ts +0 -242
- package/src/__tests__/narrative-architecture.test.ts +0 -456
- package/src/__tests__/noise-filter.test.ts +0 -205
- package/src/__tests__/noise-prototype-bank.test.ts +0 -280
- package/src/__tests__/obsidian-connector.test.ts +0 -363
- package/src/__tests__/omitted-reporting.test.ts +0 -98
- package/src/__tests__/openai-contract.test.ts +0 -513
- package/src/__tests__/pending-review.test.ts +0 -77
- package/src/__tests__/pii-redact.test.ts +0 -333
- package/src/__tests__/pivot-apply.test.ts +0 -373
- package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
- package/src/__tests__/pivot-distill.test.ts +0 -1178
- package/src/__tests__/post-retrieval-filter.test.ts +0 -178
- package/src/__tests__/ppr-traversal.test.ts +0 -256
- package/src/__tests__/prediction-engine.test.ts +0 -349
- package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
- package/src/__tests__/preference-guard-smoke.test.ts +0 -211
- package/src/__tests__/preference-matcher.test.ts +0 -282
- package/src/__tests__/preference-slots.test.ts +0 -205
- package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
- package/src/__tests__/prospective-memory.test.ts +0 -283
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
- package/src/__tests__/query-entity-detector.test.ts +0 -171
- package/src/__tests__/query-expander-alias.test.ts +0 -146
- package/src/__tests__/query-expander.test.ts +0 -34
- package/src/__tests__/recall-gate.test.ts +0 -266
- package/src/__tests__/recall-governor.test.ts +0 -227
- package/src/__tests__/recall-mode.test.ts +0 -64
- package/src/__tests__/resolve-db-path.test.ts +0 -130
- package/src/__tests__/resolve-tier.test.ts +0 -64
- package/src/__tests__/result-synthesizer.test.ts +0 -198
- package/src/__tests__/resume-context.test.ts +0 -4898
- package/src/__tests__/retrieval-eval.test.ts +0 -163
- package/src/__tests__/retrieval-trace.test.ts +0 -213
- package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
- package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
- package/src/__tests__/retroactive-boost.test.ts +0 -223
- package/src/__tests__/salience-scoring.test.ts +0 -106
- package/src/__tests__/scope-inventory.test.ts +0 -200
- package/src/__tests__/scope-policy.test.ts +0 -69
- package/src/__tests__/scope-suggester.test.ts +0 -80
- package/src/__tests__/session-checkpoint.test.ts +0 -291
- package/src/__tests__/session-distiller.test.ts +0 -640
- package/src/__tests__/session-gc.test.ts +0 -215
- package/src/__tests__/skill-engine.test.ts +0 -552
- package/src/__tests__/skill-promotion.test.ts +0 -654
- package/src/__tests__/skill-verifier.test.ts +0 -122
- package/src/__tests__/source-heartbeat.test.ts +0 -254
- package/src/__tests__/stable-context-length-contract.test.ts +0 -62
- package/src/__tests__/store-patch-metadata.test.ts +0 -235
- package/src/__tests__/store-read-consistency.test.ts +0 -122
- package/src/__tests__/store-schema-migration.test.ts +0 -87
- package/src/__tests__/store-scope-audit.test.ts +0 -81
- package/src/__tests__/store-upsert.test.ts +0 -201
- package/src/__tests__/supersede-chain.test.ts +0 -154
- package/src/__tests__/synthesis-contract.test.ts +0 -252
- package/src/__tests__/synthesis-promotion.test.ts +0 -369
- package/src/__tests__/temporal-parser.test.ts +0 -241
- package/src/__tests__/temporal-validity.test.ts +0 -160
- package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
- package/src/__tests__/term-registry.test.ts +0 -121
- package/src/__tests__/tool-error-surfacing.test.ts +0 -98
- package/src/__tests__/tool-output-compressor.test.ts +0 -160
- package/src/__tests__/topic-tag.test.ts +0 -113
- package/src/__tests__/trusted-memory.test.ts +0 -867
- package/src/__tests__/ui-server-security.test.ts +0 -93
- package/src/__tests__/usage-tracker.test.ts +0 -287
- package/src/__tests__/utility-weight.test.ts +0 -57
- package/src/__tests__/verbatim-self-recall.test.ts +0 -279
- package/src/__tests__/version-manager.test.ts +0 -248
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
- package/src/__tests__/workflow-observation.test.ts +0 -433
- package/src/__tests__/write-verifier.test.ts +0 -122
package/src/ingest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { join, basename, resolve, dirname } from "node:path";
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
15
15
|
import { redactSecrets } from "./pii-detector.js";
|
|
16
|
+
import { normalizeDedupText } from "./text-fingerprint.js";
|
|
16
17
|
import type { MemoryStore, MemoryEntry } from "./store.js";
|
|
17
18
|
import type { Embedder } from "./embedder.js";
|
|
18
19
|
import { chunkDocument, type ChunkerConfig } from "./chunker.js";
|
|
@@ -35,6 +36,8 @@ import {
|
|
|
35
36
|
import { compressToolOutput } from "./tool-output-compressor.js";
|
|
36
37
|
import { tagNarrativeIfEnabled } from "./narrative-tagger.js";
|
|
37
38
|
|
|
39
|
+
export { normalizeDedupText };
|
|
40
|
+
|
|
38
41
|
// ============================================================================
|
|
39
42
|
// Types
|
|
40
43
|
// ============================================================================
|
|
@@ -53,6 +56,8 @@ export interface IngestResult {
|
|
|
53
56
|
chunksDeduped: number;
|
|
54
57
|
dedupReasonCounts: DedupReasonCounts;
|
|
55
58
|
errors: string[];
|
|
59
|
+
/** 子 agent 独立会话文件被整文件跳过的数量(目前只有 Codex 分支会产生;见 isCodexSubagentSessionFile)。 */
|
|
60
|
+
subagentFilesSkipped?: number;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
interface ConversationTurn {
|
|
@@ -60,6 +65,29 @@ interface ConversationTurn {
|
|
|
60
65
|
text: string;
|
|
61
66
|
timestamp: string;
|
|
62
67
|
sessionId: string;
|
|
68
|
+
/**
|
|
69
|
+
* 本条记忆所在 session 里用户亲手贴的图片总数——注意是整个 session 的,
|
|
70
|
+
* 不是本轮的张数。工具产的图(截图 / 读图,实测占全部图片九成)不计。
|
|
71
|
+
*
|
|
72
|
+
* 粒度取 session 而不是轮次,是有意的(2026-08-27 定):图所在的那一轮
|
|
73
|
+
* 常常因为正文太短根本没进库,精确坐标挂不上去;而同 session 的别的轮次
|
|
74
|
+
* 进了库。打成 session 级的标,搜到那些轮次时才能顺藤摸到图。
|
|
75
|
+
*
|
|
76
|
+
* 代价是精度低:一个 session 里几十条记忆会带同一个标,图未必跟搜到的
|
|
77
|
+
* 这一条有关。这是粒度换覆盖,明知而为——判断值不值得看,回 Deja 按
|
|
78
|
+
* session 读原文,图本体不进库。
|
|
79
|
+
*/
|
|
80
|
+
sessionImages?: number;
|
|
81
|
+
/**
|
|
82
|
+
* 本 session 里 AI 自己产的图片总数:工具截图、读进来的图片文件、模型生成的图。
|
|
83
|
+
* 实测它是用户贴图的三倍多(5812 vs 1629)。
|
|
84
|
+
*
|
|
85
|
+
* 跟 sessionImages 分开计,因为两者服务的检索意图不是一回事——人找「我发过的
|
|
86
|
+
* 那张截图」要的是前者;agent 回溯「我当时生成的示意图长什么样」「那个页面当时
|
|
87
|
+
* 渲染成什么样」要的是后者。混成一个数,两种意图都答不好;只留前者,则 agent
|
|
88
|
+
* 自己回溯时看不见任何线索。
|
|
89
|
+
*/
|
|
90
|
+
sessionToolImages?: number;
|
|
63
91
|
}
|
|
64
92
|
|
|
65
93
|
// ============================================================================
|
|
@@ -88,19 +116,21 @@ export type DedupReason = "hard" | "exact" | "llm-skip" | "llm-merge" | "unique"
|
|
|
88
116
|
|
|
89
117
|
export type DedupReasonCounts = Record<DedupReason, number>;
|
|
90
118
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
/**
|
|
120
|
+
* 去重结论只回答「这条进来的切片存不存」,不带任何指向已有记忆的动作。
|
|
121
|
+
*
|
|
122
|
+
* 2026-10-05 之前这里还有一个 `secondaryDeletes`:模型可以顺手标记「过时」的已有记忆,
|
|
123
|
+
* 导入调用点原样 `store.delete`——候选不分 scope,不写审计、不记日志、错误吞掉。
|
|
124
|
+
* 2026-09-22T16:38:03Z 记忆文件导入就这样删掉了当天手写进人工维护那一层的一条偏好,
|
|
125
|
+
* 11 秒后同一轮导入写入了内容相近的文档切片;到 10-05 核对时,显式存过、不在主表、又没有
|
|
126
|
+
* forget 记录的手写记忆有 17 条。导入只新增和跳过,删除一律走 forget_memory(有隐私级别
|
|
127
|
+
* 检查与审计)。经过与数字见 CHANGELOG「Ingest dedup can no longer delete existing memories」;
|
|
128
|
+
* 回归用例 `ingest-dedup-no-deletes.test.ts`。
|
|
129
|
+
*/
|
|
98
130
|
export interface DedupCheckResult {
|
|
99
131
|
action: "store" | "skip";
|
|
100
132
|
reason: DedupReason;
|
|
101
133
|
existingText?: string;
|
|
102
|
-
/** Secondary actions on other existing memories (only populated with LLM dedup). */
|
|
103
|
-
secondaryDeletes?: DedupAction[];
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
function createDedupReasonCounts(): DedupReasonCounts {
|
|
@@ -120,26 +150,6 @@ function recordDedupDecision(result: IngestResult, decision: DedupCheckResult):
|
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
152
|
|
|
123
|
-
/**
|
|
124
|
-
* Execute secondary delete actions from a dedup decision.
|
|
125
|
-
* Errors are isolated per-action to avoid cascading failures.
|
|
126
|
-
*/
|
|
127
|
-
async function executeSecondaryDeletes(
|
|
128
|
-
store: MemoryStore,
|
|
129
|
-
deletes: DedupAction[],
|
|
130
|
-
): Promise<number> {
|
|
131
|
-
let executed = 0;
|
|
132
|
-
for (const del of deletes) {
|
|
133
|
-
try {
|
|
134
|
-
await store.delete(del.id);
|
|
135
|
-
executed++;
|
|
136
|
-
} catch {
|
|
137
|
-
// Per-action error isolation: log and continue
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return executed;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
153
|
export function getDedupSkippedCount(result: IngestResult): number {
|
|
144
154
|
return result.dedupReasonCounts.hard
|
|
145
155
|
+ result.dedupReasonCounts.exact
|
|
@@ -158,22 +168,6 @@ export function formatDedupReasonSummary(result: IngestResult): string {
|
|
|
158
168
|
return `hard:${counts.hard}, exact:${counts.exact}, llm-skip:${counts["llm-skip"]}, llm-merge:${counts["llm-merge"]}`;
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
/**
|
|
162
|
-
* Two-stage dedup: vector pre-filter + optional LLM semantic decision.
|
|
163
|
-
*
|
|
164
|
-
* Returns: "store" | "skip"
|
|
165
|
-
*
|
|
166
|
-
* Note: when the LLM says MERGE, we currently keep the new chunk instead of
|
|
167
|
-
* dropping it. We do not have a structured ingest-time merge path yet, and
|
|
168
|
-
* swallowing "same topic + new information" transcript chunks is worse for
|
|
169
|
-
* recall fidelity than storing an incremental near-duplicate.
|
|
170
|
-
*/
|
|
171
|
-
function normalizeDedupText(value: string): string {
|
|
172
|
-
return value
|
|
173
|
-
.replace(/^\[(用户|助手)\]\s*/gm, "")
|
|
174
|
-
.replace(/\s+/g, " ")
|
|
175
|
-
.trim();
|
|
176
|
-
}
|
|
177
171
|
|
|
178
172
|
/**
|
|
179
173
|
* Detect content that carries temporal events, file operations, or explicit
|
|
@@ -283,6 +277,16 @@ function shouldForceCreateToolChoicePreference(
|
|
|
283
277
|
: { shouldForceCreate: false };
|
|
284
278
|
}
|
|
285
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Two-stage dedup: vector pre-filter + optional LLM semantic decision.
|
|
282
|
+
*
|
|
283
|
+
* Returns: "store" | "skip"
|
|
284
|
+
*
|
|
285
|
+
* Note: when the LLM says MERGE, we currently keep the new chunk instead of
|
|
286
|
+
* dropping it. We do not have a structured ingest-time merge path yet, and
|
|
287
|
+
* swallowing "same topic + new information" transcript chunks is worse for
|
|
288
|
+
* recall fidelity than storing an incremental near-duplicate.
|
|
289
|
+
*/
|
|
286
290
|
export async function dedupCheck(
|
|
287
291
|
store: MemoryStore,
|
|
288
292
|
vector: number[],
|
|
@@ -392,8 +396,6 @@ export async function dedupCheck(
|
|
|
392
396
|
const hasMulti = typeof (llm as any).dedupDecisionMulti === "function";
|
|
393
397
|
|
|
394
398
|
let primaryAction: "CREATE" | "MERGE" | "SKIP" = "CREATE";
|
|
395
|
-
let primaryReason = "";
|
|
396
|
-
const secondaryDeletes: DedupAction[] = [];
|
|
397
399
|
|
|
398
400
|
if (hasMulti) {
|
|
399
401
|
const candidates = results
|
|
@@ -401,48 +403,20 @@ export async function dedupCheck(
|
|
|
401
403
|
.slice(0, DEDUP_CANDIDATE_LIMIT)
|
|
402
404
|
.map(r => ({ id: r.entry.id, text: r.entry.text }));
|
|
403
405
|
|
|
406
|
+
// 只取「存 / 并 / 跳」这一个结论。候选来自全库(不分 scope),模型对它们没有处置权,
|
|
407
|
+
// 见 DedupCheckResult 上方的说明。
|
|
404
408
|
const decision = await llm.dedupDecisionMulti(text, candidates);
|
|
405
409
|
primaryAction = decision.action;
|
|
406
|
-
primaryReason = decision.reason;
|
|
407
|
-
|
|
408
|
-
// Build secondary deletes from validated actions (resolve IDs at parse time)
|
|
409
|
-
if (decision.actions) {
|
|
410
|
-
for (const act of decision.actions) {
|
|
411
|
-
const target = candidates[act.match_index - 1];
|
|
412
|
-
if (target) {
|
|
413
|
-
secondaryDeletes.push({
|
|
414
|
-
id: target.id,
|
|
415
|
-
action: "delete",
|
|
416
|
-
reason: typeof act.reason === "string" ? act.reason : "",
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
410
|
} else {
|
|
422
411
|
const decision = await llm.dedupDecision(text, existingText);
|
|
423
412
|
primaryAction = decision.action;
|
|
424
|
-
primaryReason = decision.reason;
|
|
425
413
|
}
|
|
426
414
|
|
|
427
415
|
if (primaryAction === "SKIP") {
|
|
428
|
-
return {
|
|
429
|
-
action: "skip",
|
|
430
|
-
reason: "llm-skip",
|
|
431
|
-
existingText,
|
|
432
|
-
...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
|
|
433
|
-
};
|
|
416
|
+
return { action: "skip", reason: "llm-skip", existingText };
|
|
434
417
|
}
|
|
435
418
|
if (primaryAction === "MERGE") {
|
|
436
|
-
return {
|
|
437
|
-
action: "store",
|
|
438
|
-
reason: "llm-merge",
|
|
439
|
-
existingText,
|
|
440
|
-
...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
// CREATE — still execute secondary deletes if any
|
|
444
|
-
if (secondaryDeletes.length > 0) {
|
|
445
|
-
return { action: "store", reason: "unique", secondaryDeletes };
|
|
419
|
+
return { action: "store", reason: "llm-merge", existingText };
|
|
446
420
|
}
|
|
447
421
|
} catch {
|
|
448
422
|
// LLM failed, fall through to store
|
|
@@ -493,7 +467,7 @@ function fallbackExtraction(text: string): SmartExtraction {
|
|
|
493
467
|
* Uses LLM 6-category extraction when available, falls back to heuristic.
|
|
494
468
|
* Returns: category + L0 + L1 + importance for each text.
|
|
495
469
|
*/
|
|
496
|
-
async function smartExtractBatch(
|
|
470
|
+
export async function smartExtractBatch(
|
|
497
471
|
texts: string[],
|
|
498
472
|
llm?: LLMClient | null,
|
|
499
473
|
): Promise<SmartExtraction[]> {
|
|
@@ -522,7 +496,7 @@ function isCoreSummaryEnabled(): boolean {
|
|
|
522
496
|
* Only runs when RECALLNEST_CORE_SUMMARY=true and LLM is available.
|
|
523
497
|
* Returns null array when disabled (no overhead).
|
|
524
498
|
*/
|
|
525
|
-
async function generateCoreSummaries(
|
|
499
|
+
export async function generateCoreSummaries(
|
|
526
500
|
texts: string[],
|
|
527
501
|
llm?: LLMClient | null,
|
|
528
502
|
): Promise<(string | null)[]> {
|
|
@@ -649,13 +623,20 @@ export async function drainPendingQueue(
|
|
|
649
623
|
const entryText = extractions[j].l1 || extractions[j].l0;
|
|
650
624
|
const language = detectLang(entryText);
|
|
651
625
|
const fts_text = tokenizeFts(entryText, language);
|
|
652
|
-
|
|
626
|
+
// 2026-08-24 修:回填路径此前直接 store.store(),绕过 buildIngestedEntry,
|
|
627
|
+
// 导致条目缺 boundary/layer/tier(transcript 碎片不降权)。改走同一构造器;
|
|
628
|
+
// 入队时未保留原文件路径,file 用 "pending-queue" 诚实标记回填来源。
|
|
629
|
+
const entry = buildIngestedEntry({
|
|
630
|
+
source: batch[j].scope.split(":")[0],
|
|
631
|
+
scope: batch[j].scope,
|
|
653
632
|
text: entryText,
|
|
654
633
|
vector: vectors[j],
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
634
|
+
extraction: extractions[j],
|
|
635
|
+
file: "pending-queue",
|
|
636
|
+
});
|
|
637
|
+
await store.store({
|
|
638
|
+
...entry,
|
|
639
|
+
category: entry.category as any,
|
|
659
640
|
language,
|
|
660
641
|
fts_text,
|
|
661
642
|
});
|
|
@@ -685,7 +666,7 @@ function initialTier(extraction: Pick<SmartExtraction, "category" | "importance"
|
|
|
685
666
|
return "peripheral";
|
|
686
667
|
}
|
|
687
668
|
|
|
688
|
-
function buildIngestedEntry(params: {
|
|
669
|
+
export function buildIngestedEntry(params: {
|
|
689
670
|
source: string;
|
|
690
671
|
scope: string;
|
|
691
672
|
text: string;
|
|
@@ -696,6 +677,10 @@ function buildIngestedEntry(params: {
|
|
|
696
677
|
heading?: string;
|
|
697
678
|
/** Tier 3.1: optional core summary (≤200 chars) for token-efficient context output */
|
|
698
679
|
coreSummary?: string | null;
|
|
680
|
+
/** 本条记忆所在 session 里用户亲手贴的图片总数;无图时不传 */
|
|
681
|
+
sessionImages?: number;
|
|
682
|
+
/** 本条记忆所在 session 里 AI 自己产的图片总数(截图 / 读图 / 生图);无则不传 */
|
|
683
|
+
sessionToolImages?: number;
|
|
699
684
|
}): {
|
|
700
685
|
text: string;
|
|
701
686
|
vector: number[];
|
|
@@ -739,6 +724,8 @@ function buildIngestedEntry(params: {
|
|
|
739
724
|
l1_overview: params.extraction.l1,
|
|
740
725
|
l2_content: params.text,
|
|
741
726
|
...(params.coreSummary ? { core_summary: params.coreSummary } : {}),
|
|
727
|
+
...(params.sessionImages ? { sessionImages: params.sessionImages } : {}),
|
|
728
|
+
...(params.sessionToolImages ? { sessionToolImages: params.sessionToolImages } : {}),
|
|
742
729
|
tier,
|
|
743
730
|
boundary: resolution.boundary,
|
|
744
731
|
...(narrative ? { narrative } : {}),
|
|
@@ -786,16 +773,77 @@ const MARKDOWN_CHUNK_CONFIG: ChunkerConfig = {
|
|
|
786
773
|
// CC Transcript Parser
|
|
787
774
|
// ============================================================================
|
|
788
775
|
|
|
789
|
-
|
|
776
|
+
/**
|
|
777
|
+
* 数一条 transcript 记录里所有的图片信号,**不预设它们是什么用途、在什么位置**。
|
|
778
|
+
*
|
|
779
|
+
* 这是有意的宽口径:AI 产图的形态一直在长(工具截图、读进来的图片文件、模型生图、
|
|
780
|
+
* 写公众号时让它配的插图……),任何"列举有哪几类"的写法都注定漏掉下一类。所以这里
|
|
781
|
+
* 只回答一个问题——「这是不是一张图」,然后由调用方用**补集**去定义 AI 产图:
|
|
782
|
+
* 整条记录里的图 减去 用户在消息里亲手贴的,剩下的全算,不问出身。
|
|
783
|
+
*
|
|
784
|
+
* 判据是「携带图片的块」的三种通用形态,跨 Anthropic / OpenAI / Kimi 都成立:
|
|
785
|
+
* - type 里含 image(image / input_image / image_url / image_generation_* …)
|
|
786
|
+
* - 有 image_url / imageUrl 字段
|
|
787
|
+
* - 有 media_type / mimeType 且以 image/ 开头
|
|
788
|
+
*
|
|
789
|
+
* ⚠️ 这是**信号计数不是精确张数**:一次生图可能同时留下 call 与 end 两条记录,
|
|
790
|
+
* 会被数成 2。这是刻意选的方向——宁可多算,不可漏报,因为它服务的问题是
|
|
791
|
+
* 「这附近有没有图」而不是「精确几张」。
|
|
792
|
+
*/
|
|
793
|
+
function countImageSignals(node: unknown, depth = 0): number {
|
|
794
|
+
if (depth > 10 || node === null || typeof node !== "object") return 0;
|
|
795
|
+
if (Array.isArray(node)) {
|
|
796
|
+
let sum = 0;
|
|
797
|
+
for (const item of node) sum += countImageSignals(item, depth + 1);
|
|
798
|
+
return sum;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const obj = node as Record<string, unknown>;
|
|
802
|
+
const type = obj.type;
|
|
803
|
+
const mediaType = obj.media_type ?? obj.mimeType;
|
|
804
|
+
const isImageBlock =
|
|
805
|
+
(typeof type === "string" && type.includes("image")) ||
|
|
806
|
+
typeof obj.image_url === "string" ||
|
|
807
|
+
(obj.imageUrl !== null && typeof obj.imageUrl === "object") ||
|
|
808
|
+
(typeof mediaType === "string" && mediaType.startsWith("image/"));
|
|
809
|
+
|
|
810
|
+
// 命中即计一次并停止下钻:一个图片块内部不会再嵌另一张独立的图,继续递归
|
|
811
|
+
// 只会把 {type:"image", source:{media_type:"image/png"}} 这种数成两张。
|
|
812
|
+
if (isImageBlock) return 1;
|
|
813
|
+
|
|
814
|
+
let count = 0;
|
|
815
|
+
for (const value of Object.values(obj)) {
|
|
816
|
+
if (value !== null && typeof value === "object") {
|
|
817
|
+
count += countImageSignals(value, depth + 1);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return count;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
790
824
|
const turns: ConversationTurn[] = [];
|
|
791
825
|
const content = readFileSync(filePath, "utf-8");
|
|
792
826
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
793
827
|
|
|
794
828
|
let sessionId = "";
|
|
829
|
+
// 整个 session 里的图片计数,扫完全文后统一盖到每个 turn 上。两类分开数:
|
|
830
|
+
// 用户亲手贴的 vs AI 自己产的(工具截图 / 读图 / 生图)。
|
|
831
|
+
// 数得到是因为本函数本来就逐行读完整个文件,顺带累加不额外花钱。
|
|
832
|
+
let sessionImages = 0;
|
|
833
|
+
let sessionToolImages = 0;
|
|
795
834
|
|
|
796
835
|
for (const line of lines) {
|
|
797
836
|
try {
|
|
798
837
|
const obj = JSON.parse(line);
|
|
838
|
+
// 子 agent 侧链(isSidechain:true)的 user 行是主 agent 写给子 agent 的任务信封,不是人说的话。
|
|
839
|
+
// 今天 CC 把侧链分文件存在 <session>/subagents/agent-*.jsonl,而本函数的调用方只读项目目录顶层
|
|
840
|
+
// 的 .jsonl,所以这一行当前不会命中;留着是防 CC 哪天把侧链内联回主文件(2026-09-08 借鉴 ECC
|
|
841
|
+
// observe.sh 的「按结构字段认谁在说、不认 role」)。
|
|
842
|
+
if (obj.isSidechain === true) continue;
|
|
843
|
+
// 这一行里所有的图片信号。用户亲手贴的那部分在下面单独精确认,
|
|
844
|
+
// 其余的一律按补集归入 AI 产图——不枚举它是截图、读图、生图还是配图。
|
|
845
|
+
const lineImageSignals = countImageSignals(obj);
|
|
846
|
+
let lineUserImages = 0;
|
|
799
847
|
|
|
800
848
|
if (!sessionId && obj.sessionId) {
|
|
801
849
|
sessionId = obj.sessionId;
|
|
@@ -804,20 +852,39 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
|
804
852
|
if (obj.type === "user" && obj.message) {
|
|
805
853
|
const msg = obj.message;
|
|
806
854
|
let text = "";
|
|
855
|
+
let turnImages = 0;
|
|
807
856
|
if (typeof msg.content === "string") {
|
|
808
857
|
text = msg.content;
|
|
809
858
|
} else if (Array.isArray(msg.content)) {
|
|
810
|
-
//
|
|
859
|
+
// 文本块照旧提取,tool_use / tool_result 仍然整块丢弃(架构决定,见 memory-boundaries)。
|
|
860
|
+
// 图片本体也不进库,只数一下:这里只认顶层的 image block,
|
|
861
|
+
// tool_result 里内嵌的不算——那是工具截图/读图,实测占全部图片九成。
|
|
811
862
|
text = msg.content
|
|
812
863
|
.filter((c: any) => c.type === "text")
|
|
813
864
|
.map((c: any) => c.text || "")
|
|
814
865
|
.join("\n");
|
|
866
|
+
turnImages = msg.content.filter((c: any) => c?.type === "image").length;
|
|
815
867
|
}
|
|
816
|
-
|
|
817
|
-
|
|
868
|
+
sessionImages += turnImages;
|
|
869
|
+
lineUserImages = turnImages;
|
|
870
|
+
|
|
871
|
+
// 只贴图不配字的那一轮,此前会被长度闸整条丢掉——实测占含图轮次的 12.5%,
|
|
872
|
+
// 且丢掉的常是「操作步骤在这里」+ 截图这类正文全在图里的高价值轮次
|
|
873
|
+
// (最极端的样本是 0 字配 7 张图)。给它一个占位正文,让它能跟下一条
|
|
874
|
+
// 助手回复合成 chunk——助手的回复通常描述了图里是什么,那就是定位这些图的文字。
|
|
875
|
+
//
|
|
876
|
+
// 有配文时正文一个字都不改:文字本身已是检索入口,往里塞标记只会稀释 embedding。
|
|
877
|
+
const trimmed = text.trim();
|
|
878
|
+
const body = trimmed.length > 0
|
|
879
|
+
? trimmed
|
|
880
|
+
: turnImages > 0
|
|
881
|
+
? `[图片×${turnImages}]`
|
|
882
|
+
: "";
|
|
883
|
+
|
|
884
|
+
if (body.length > 10 || turnImages > 0) {
|
|
818
885
|
turns.push({
|
|
819
886
|
role: "user",
|
|
820
|
-
text:
|
|
887
|
+
text: body,
|
|
821
888
|
timestamp: obj.timestamp || "",
|
|
822
889
|
sessionId,
|
|
823
890
|
});
|
|
@@ -846,11 +913,26 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
|
846
913
|
});
|
|
847
914
|
}
|
|
848
915
|
}
|
|
916
|
+
|
|
917
|
+
// 补集:这一行里除用户亲手贴的之外的图,全算 AI 产的,不问出身。
|
|
918
|
+
// 用补集而不是列举,是因为 AI 产图的形态会一直长(生图、截图、读图、
|
|
919
|
+
// 写文章时的配图……),任何枚举都注定漏掉下一类。
|
|
920
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
849
921
|
} catch {
|
|
850
922
|
// Skip malformed lines
|
|
851
923
|
}
|
|
852
924
|
}
|
|
853
925
|
|
|
926
|
+
// 整份 transcript 扫完才知道总数,统一盖到每个 turn 上——这条标记回答的是
|
|
927
|
+
// 「这条记忆所在的 session 里有没有图」,不是「这一轮有没有图」。图所在的那轮
|
|
928
|
+
// 常因正文太短进不了库,只有盖到同 session 的其他轮次上,才可能被搜到。
|
|
929
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
930
|
+
for (const turn of turns) {
|
|
931
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
932
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
854
936
|
return turns;
|
|
855
937
|
}
|
|
856
938
|
|
|
@@ -872,12 +954,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
872
954
|
text: string;
|
|
873
955
|
timestamp: string;
|
|
874
956
|
sessionId: string;
|
|
957
|
+
sessionImages?: number;
|
|
958
|
+
sessionToolImages?: number;
|
|
875
959
|
}> {
|
|
876
960
|
const filtered = filterNoiseTurns(turns);
|
|
877
|
-
const chunks: Array<{
|
|
961
|
+
const chunks: Array<{
|
|
962
|
+
text: string;
|
|
963
|
+
timestamp: string;
|
|
964
|
+
sessionId: string;
|
|
965
|
+
sessionImages?: number;
|
|
966
|
+
sessionToolImages?: number;
|
|
967
|
+
}> = [];
|
|
878
968
|
|
|
879
969
|
for (let i = 0; i < filtered.length; i++) {
|
|
880
970
|
const turn = filtered[i];
|
|
971
|
+
// 同一 session 切出来的每个 chunk 都带同一个标——它说的是「这个 session 有图」,
|
|
972
|
+
// 跟这一片本身有没有图无关,这正是 session 粒度要的效果。
|
|
973
|
+
const imgMark = {
|
|
974
|
+
...(turn.sessionImages ? { sessionImages: turn.sessionImages } : {}),
|
|
975
|
+
...(turn.sessionToolImages ? { sessionToolImages: turn.sessionToolImages } : {}),
|
|
976
|
+
};
|
|
881
977
|
|
|
882
978
|
// If this is a user turn followed by an assistant turn, merge them
|
|
883
979
|
if (turn.role === "user" && i + 1 < filtered.length && filtered[i + 1].role === "assistant") {
|
|
@@ -896,10 +992,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
896
992
|
if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
|
|
897
993
|
const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
|
|
898
994
|
for (const chunk of chunkResult.chunks) {
|
|
899
|
-
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
995
|
+
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
900
996
|
}
|
|
901
997
|
} else {
|
|
902
|
-
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
998
|
+
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
903
999
|
}
|
|
904
1000
|
}
|
|
905
1001
|
} else {
|
|
@@ -907,6 +1003,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
907
1003
|
text: merged,
|
|
908
1004
|
timestamp: turn.timestamp,
|
|
909
1005
|
sessionId: turn.sessionId,
|
|
1006
|
+
...imgMark,
|
|
910
1007
|
});
|
|
911
1008
|
}
|
|
912
1009
|
|
|
@@ -925,10 +1022,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
925
1022
|
if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
|
|
926
1023
|
const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
|
|
927
1024
|
for (const chunk of chunkResult.chunks) {
|
|
928
|
-
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1025
|
+
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
929
1026
|
}
|
|
930
1027
|
} else {
|
|
931
|
-
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1028
|
+
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
932
1029
|
}
|
|
933
1030
|
}
|
|
934
1031
|
} else {
|
|
@@ -936,6 +1033,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
936
1033
|
text,
|
|
937
1034
|
timestamp: turn.timestamp,
|
|
938
1035
|
sessionId: turn.sessionId,
|
|
1036
|
+
...imgMark,
|
|
939
1037
|
});
|
|
940
1038
|
}
|
|
941
1039
|
}
|
|
@@ -959,18 +1057,48 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
959
1057
|
// Codex Session Parser
|
|
960
1058
|
// ============================================================================
|
|
961
1059
|
|
|
962
|
-
|
|
1060
|
+
/**
|
|
1061
|
+
* Codex multi_agent 派出的子 agent 会话是独立的 rollout 文件,首行 session_meta 带 parent_thread_id
|
|
1062
|
+
* (另有 agent_nickname / agent_role / multi_agent_version)。文件里 role=user 的 input_text 是主 agent
|
|
1063
|
+
* 派的任务,不是人说的话——2026-09-08 实测本机 5,597 个 Codex 会话文件里 2,555 个(45.6%)是子 agent 文件,
|
|
1064
|
+
* 其 user turn 占全部 user turn 的 46.7%(近 7 天 63.7%)。ingest 前整文件跳过,与 Kimi 分支只读
|
|
1065
|
+
* agents/main/wire.jsonl 同一个判据:按结构字段认「谁在说」,不认 role。只读首行,不整读文件。
|
|
1066
|
+
*/
|
|
1067
|
+
export function isCodexSubagentSessionFile(filePath: string): boolean {
|
|
1068
|
+
try {
|
|
1069
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1070
|
+
const nl = content.indexOf("\n");
|
|
1071
|
+
const first = (nl === -1 ? content : content.slice(0, nl)).trim();
|
|
1072
|
+
if (!first) return false;
|
|
1073
|
+
const obj = JSON.parse(first);
|
|
1074
|
+
if (obj?.type !== "session_meta") return false;
|
|
1075
|
+
const parent = obj.payload?.parent_thread_id;
|
|
1076
|
+
return typeof parent === "string" && parent.length > 0;
|
|
1077
|
+
} catch {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
963
1083
|
const turns: ConversationTurn[] = [];
|
|
964
1084
|
const content = readFileSync(filePath, "utf-8");
|
|
965
1085
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
966
1086
|
|
|
967
1087
|
let sessionId = "";
|
|
1088
|
+
// 同 parseCCTranscript:两类图片分开计数。
|
|
1089
|
+
// Codex 的图片字段是 input_image(OpenAI 格式),不是 Anthropic 的 image;
|
|
1090
|
+
// 用户贴的在 payload.content 里可以精确认;其余一律按补集归 AI 产图。
|
|
1091
|
+
let sessionImages = 0;
|
|
1092
|
+
let sessionToolImages = 0;
|
|
968
1093
|
|
|
969
1094
|
for (const line of lines) {
|
|
970
1095
|
try {
|
|
971
1096
|
const obj = JSON.parse(line);
|
|
972
1097
|
const payload = obj.payload;
|
|
973
1098
|
const timestamp = obj.timestamp || "";
|
|
1099
|
+
// 同 parseCCTranscript:整行图片信号,减去用户贴的,剩下全归 AI 产
|
|
1100
|
+
const lineImageSignals = countImageSignals(obj);
|
|
1101
|
+
let lineUserImages = 0;
|
|
974
1102
|
|
|
975
1103
|
if (obj.type === "session_meta" && payload?.id) {
|
|
976
1104
|
sessionId = payload.id;
|
|
@@ -982,6 +1110,13 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
|
982
1110
|
const content = payload.content;
|
|
983
1111
|
|
|
984
1112
|
if (Array.isArray(content)) {
|
|
1113
|
+
// 只精确认用户消息里的 input_image;Codex 自己生的图 / 读的图挂在
|
|
1114
|
+
// payload.output 等别处,由下面的补集统一接住,这里不去枚举它们。
|
|
1115
|
+
if (role === "user") {
|
|
1116
|
+
const own = content.filter((c: any) => c?.type === "input_image").length;
|
|
1117
|
+
sessionImages += own;
|
|
1118
|
+
lineUserImages += own;
|
|
1119
|
+
}
|
|
985
1120
|
for (const c of content) {
|
|
986
1121
|
if (c.type === "input_text" && c.text && c.text.length > 10) {
|
|
987
1122
|
// Skip system/developer prompts (usually very long instructions)
|
|
@@ -1023,11 +1158,21 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
|
1023
1158
|
}
|
|
1024
1159
|
}
|
|
1025
1160
|
}
|
|
1161
|
+
|
|
1162
|
+
// 补集:除用户亲手贴的之外,这一行里的图全算 AI 产的,不问出身。
|
|
1163
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
1026
1164
|
} catch {
|
|
1027
1165
|
// Skip malformed lines
|
|
1028
1166
|
}
|
|
1029
1167
|
}
|
|
1030
1168
|
|
|
1169
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
1170
|
+
for (const turn of turns) {
|
|
1171
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
1172
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1031
1176
|
return turns;
|
|
1032
1177
|
}
|
|
1033
1178
|
|
|
@@ -1099,6 +1244,14 @@ export async function ingestCodexSessions(
|
|
|
1099
1244
|
continue;
|
|
1100
1245
|
}
|
|
1101
1246
|
|
|
1247
|
+
// 子 agent 独立会话整文件跳过(判据与理由见 isCodexSubagentSessionFile)。标记 processed 是为了
|
|
1248
|
+
// 下一轮非 --recent 的增量不再重读它;子 agent 的结论本就该由主 agent 落盘,原文由 Deja 全量索引兜底。
|
|
1249
|
+
if (isCodexSubagentSessionFile(filePath)) {
|
|
1250
|
+
result.subagentFilesSkipped = (result.subagentFilesSkipped ?? 0) + 1;
|
|
1251
|
+
markProcessed(filePath, stat.size, 0, stat.mtimeMs);
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1102
1255
|
const turns = parseCodexSession(filePath);
|
|
1103
1256
|
if (turns.length === 0) {
|
|
1104
1257
|
result.chunksSkipped++;
|
|
@@ -1133,9 +1286,6 @@ export async function ingestCodexSessions(
|
|
|
1133
1286
|
if (!options.noDedup) {
|
|
1134
1287
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
1135
1288
|
recordDedupDecision(result, decision);
|
|
1136
|
-
if (decision.secondaryDeletes?.length) {
|
|
1137
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
1138
|
-
}
|
|
1139
1289
|
if (decision.action === "skip") continue;
|
|
1140
1290
|
}
|
|
1141
1291
|
dedupedTexts.push(batch[j]);
|
|
@@ -1164,6 +1314,8 @@ export async function ingestCodexSessions(
|
|
|
1164
1314
|
sessionId: chunk.sessionId,
|
|
1165
1315
|
file: basename(filePath),
|
|
1166
1316
|
coreSummary: coreSummaries[j],
|
|
1317
|
+
sessionImages: chunk.sessionImages,
|
|
1318
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1167
1319
|
}));
|
|
1168
1320
|
}
|
|
1169
1321
|
}
|
|
@@ -1205,7 +1357,7 @@ export async function ingestCodexSessions(
|
|
|
1205
1357
|
// context.append_loop_event content.part → 助手文本(part.type==="text";think 跳过)
|
|
1206
1358
|
// 其余类型(llm.tools_snapshot / mcp.tools_discovered / config.update / llm.request /
|
|
1207
1359
|
// usage.record / permission.* 等,约占 86% 体积)是运行事件,一律跳过。
|
|
1208
|
-
function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
1360
|
+
export function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
1209
1361
|
const turns: ConversationTurn[] = [];
|
|
1210
1362
|
const content = readFileSync(filePath, "utf-8");
|
|
1211
1363
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
@@ -1215,6 +1367,11 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1215
1367
|
const pathParts = filePath.split("/");
|
|
1216
1368
|
const rawId = pathParts.length >= 4 ? pathParts[pathParts.length - 4] : basename(filePath, ".jsonl");
|
|
1217
1369
|
const sessionId = rawId.replace(/^session_/, "");
|
|
1370
|
+
// 同 parseCCTranscript:两类图片分开计数。
|
|
1371
|
+
// Kimi 的图片字段是 image_url;用户贴的在 turn.prompt / context.append_message,
|
|
1372
|
+
// AI 产的在 context.append_loop_event 的 tool.result 里。
|
|
1373
|
+
let sessionImages = 0;
|
|
1374
|
+
let sessionToolImages = 0;
|
|
1218
1375
|
|
|
1219
1376
|
const pushTurn = (role: "user" | "assistant", text: string, timestamp: string) => {
|
|
1220
1377
|
const trimmed = text.trim();
|
|
@@ -1231,6 +1388,23 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1231
1388
|
const obj = JSON.parse(line);
|
|
1232
1389
|
const timestamp = typeof obj.time === "number" ? new Date(obj.time).toISOString() : "";
|
|
1233
1390
|
|
|
1391
|
+
// 图片统计在这里一次算完,不放进下面的分支——那些分支各自 continue,
|
|
1392
|
+
// 放进去会被跳过。用户贴的精确认,其余按补集归 AI 产,不枚举出身。
|
|
1393
|
+
{
|
|
1394
|
+
const lineImageSignals = countImageSignals(obj);
|
|
1395
|
+
let lineUserImages = 0;
|
|
1396
|
+
if (obj.type === "turn.prompt" && obj.origin?.kind === "user" && Array.isArray(obj.input)) {
|
|
1397
|
+
lineUserImages = obj.input.filter((c: any) => c?.type === "image_url").length;
|
|
1398
|
+
} else if (obj.type === "context.append_message") {
|
|
1399
|
+
const m = obj.message;
|
|
1400
|
+
if (m?.role === "user" && m?.origin?.kind === "user" && Array.isArray(m.content)) {
|
|
1401
|
+
lineUserImages = m.content.filter((c: any) => c?.type === "image_url").length;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
sessionImages += lineUserImages;
|
|
1405
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1234
1408
|
if (obj.type === "turn.prompt") {
|
|
1235
1409
|
if (obj.origin?.kind !== "user") continue;
|
|
1236
1410
|
const parts = Array.isArray(obj.input) ? obj.input : [];
|
|
@@ -1271,6 +1445,13 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1271
1445
|
}
|
|
1272
1446
|
}
|
|
1273
1447
|
|
|
1448
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
1449
|
+
for (const turn of turns) {
|
|
1450
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
1451
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1274
1455
|
return turns;
|
|
1275
1456
|
}
|
|
1276
1457
|
|
|
@@ -1403,9 +1584,6 @@ export async function ingestKimiSessions(
|
|
|
1403
1584
|
if (!options.noDedup) {
|
|
1404
1585
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
1405
1586
|
recordDedupDecision(result, decision);
|
|
1406
|
-
if (decision.secondaryDeletes?.length) {
|
|
1407
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
1408
|
-
}
|
|
1409
1587
|
if (decision.action === "skip") continue;
|
|
1410
1588
|
}
|
|
1411
1589
|
dedupedTexts.push(batch[j]);
|
|
@@ -1434,6 +1612,8 @@ export async function ingestKimiSessions(
|
|
|
1434
1612
|
sessionId: chunk.sessionId,
|
|
1435
1613
|
file: basename(filePath),
|
|
1436
1614
|
coreSummary: coreSummaries[j],
|
|
1615
|
+
sessionImages: chunk.sessionImages,
|
|
1616
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1437
1617
|
}));
|
|
1438
1618
|
}
|
|
1439
1619
|
}
|
|
@@ -1606,9 +1786,6 @@ export async function ingestGeminiSessions(
|
|
|
1606
1786
|
if (!options.noDedup) {
|
|
1607
1787
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
1608
1788
|
recordDedupDecision(result, decision);
|
|
1609
|
-
if (decision.secondaryDeletes?.length) {
|
|
1610
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
1611
|
-
}
|
|
1612
1789
|
if (decision.action === "skip") continue;
|
|
1613
1790
|
}
|
|
1614
1791
|
dedupedTexts.push(batch[j]);
|
|
@@ -1676,7 +1853,7 @@ export async function ingestGeminiSessions(
|
|
|
1676
1853
|
* Split a markdown file by headings (## or #) into chunks.
|
|
1677
1854
|
* Each chunk includes the heading + content under it.
|
|
1678
1855
|
*/
|
|
1679
|
-
function parseMarkdown(filePath: string): Array<{ text: string; heading: string }> {
|
|
1856
|
+
export function parseMarkdown(filePath: string): Array<{ text: string; heading: string }> {
|
|
1680
1857
|
const content = readFileSync(filePath, "utf-8");
|
|
1681
1858
|
const chunks: Array<{ text: string; heading: string }> = [];
|
|
1682
1859
|
|
|
@@ -1758,6 +1935,12 @@ export async function ingestCCTranscripts(
|
|
|
1758
1935
|
};
|
|
1759
1936
|
|
|
1760
1937
|
const dir = expandHome(sourcePath);
|
|
1938
|
+
// session-digest.py 摘要调用留下的会话记录不 ingest(2026-08-24 审计:这类
|
|
1939
|
+
// transcript 是原始会话 8000 字符采样的副本,ingest 后 scope/metadata 指向
|
|
1940
|
+
// 摘要任务自身 sid——内容是真的、坐标是假的,已产生 1,950 条 evidence 污染)。
|
|
1941
|
+
if (dir.includes("session-digest")) {
|
|
1942
|
+
return result;
|
|
1943
|
+
}
|
|
1761
1944
|
if (!existsSync(dir)) {
|
|
1762
1945
|
result.errors.push(`Directory not found: ${dir}`);
|
|
1763
1946
|
return result;
|
|
@@ -1836,9 +2019,6 @@ export async function ingestCCTranscripts(
|
|
|
1836
2019
|
if (!options.noDedup) {
|
|
1837
2020
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
1838
2021
|
recordDedupDecision(result, decision);
|
|
1839
|
-
if (decision.secondaryDeletes?.length) {
|
|
1840
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
1841
|
-
}
|
|
1842
2022
|
if (decision.action === "skip") continue;
|
|
1843
2023
|
}
|
|
1844
2024
|
|
|
@@ -1879,6 +2059,8 @@ export async function ingestCCTranscripts(
|
|
|
1879
2059
|
sessionId: chunk.sessionId,
|
|
1880
2060
|
file,
|
|
1881
2061
|
coreSummary: coreSummaries[j],
|
|
2062
|
+
sessionImages: chunk.sessionImages,
|
|
2063
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1882
2064
|
}));
|
|
1883
2065
|
}
|
|
1884
2066
|
|
|
@@ -1988,9 +2170,6 @@ export async function ingestMarkdownFiles(
|
|
|
1988
2170
|
if (!options.noDedup) {
|
|
1989
2171
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
1990
2172
|
recordDedupDecision(result, decision);
|
|
1991
|
-
if (decision.secondaryDeletes?.length) {
|
|
1992
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
1993
|
-
}
|
|
1994
2173
|
if (decision.action === "skip") continue;
|
|
1995
2174
|
}
|
|
1996
2175
|
dedupedTexts.push(batch[j]);
|
|
@@ -2122,9 +2301,6 @@ export async function ingestGenericText(
|
|
|
2122
2301
|
if (!options.noDedup) {
|
|
2123
2302
|
const decision = await dedupCheck(store, vector, batch[j], options.llm);
|
|
2124
2303
|
recordDedupDecision(result, decision);
|
|
2125
|
-
if (decision.secondaryDeletes?.length) {
|
|
2126
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
2127
|
-
}
|
|
2128
2304
|
if (decision.action === "skip") continue;
|
|
2129
2305
|
}
|
|
2130
2306
|
dedupedTexts.push(batch[j]);
|
|
@@ -2241,9 +2417,6 @@ export async function ingestConnectorFile(
|
|
|
2241
2417
|
if (!options.noDedup) {
|
|
2242
2418
|
const decision = await dedupCheck(store, vector, texts[j], options.llm);
|
|
2243
2419
|
recordDedupDecision(result, decision);
|
|
2244
|
-
if (decision.secondaryDeletes?.length) {
|
|
2245
|
-
await executeSecondaryDeletes(store, decision.secondaryDeletes);
|
|
2246
|
-
}
|
|
2247
2420
|
if (decision.action === "skip") continue;
|
|
2248
2421
|
}
|
|
2249
2422
|
dedupedTexts.push(texts[j]);
|