recallnest 2.6.1 → 3.0.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/CHANGELOG.md +292 -0
- package/README.md +280 -195
- package/README_CN.md +266 -199
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +7 -5
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/capture-engine.ts +26 -4
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/context-composer-stable.ts +5 -1
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +44 -5
- package/src/env-config.ts +71 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +248 -22
- package/src/kg-extractor.ts +17 -0
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +47 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/pivot-apply.ts +52 -1
- package/src/pivot-evidence.ts +128 -0
- package/src/ppr-traversal.ts +25 -1
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/runtime-config.ts +9 -0
- package/src/scope-policy.ts +22 -1
- package/src/session-engine.ts +11 -4
- package/src/session-output.ts +13 -4
- package/src/session-store.ts +2 -3
- package/src/skill-promotion.ts +218 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- package/src/__tests__/access-tracker-novelty.test.ts +0 -304
- 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__/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 -945
- 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 -430
- 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 -276
- 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 -780
- 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.test.ts +0 -151
- 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.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 -189
- 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 -345
- 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 -215
- package/src/__tests__/memory-promotion.test.ts +0 -315
- 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__/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__/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__/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 -250
- package/src/__tests__/stable-context-length-contract.test.ts +0 -62
- package/src/__tests__/store-patch-metadata.test.ts +0 -205
- 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__/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 -839
- package/src/__tests__/ui-server-security.test.ts +0 -93
- package/src/__tests__/usage-tracker.test.ts +0 -287
- package/src/__tests__/version-manager.test.ts +0 -213
- 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/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/ingest.ts
CHANGED
|
@@ -60,6 +60,29 @@ interface ConversationTurn {
|
|
|
60
60
|
text: string;
|
|
61
61
|
timestamp: string;
|
|
62
62
|
sessionId: string;
|
|
63
|
+
/**
|
|
64
|
+
* 本条记忆所在 session 里用户亲手贴的图片总数——注意是整个 session 的,
|
|
65
|
+
* 不是本轮的张数。工具产的图(截图 / 读图,实测占全部图片九成)不计。
|
|
66
|
+
*
|
|
67
|
+
* 粒度取 session 而不是轮次,是有意的(2026-08-27 定):图所在的那一轮
|
|
68
|
+
* 常常因为正文太短根本没进库,精确坐标挂不上去;而同 session 的别的轮次
|
|
69
|
+
* 进了库。打成 session 级的标,搜到那些轮次时才能顺藤摸到图。
|
|
70
|
+
*
|
|
71
|
+
* 代价是精度低:一个 session 里几十条记忆会带同一个标,图未必跟搜到的
|
|
72
|
+
* 这一条有关。这是粒度换覆盖,明知而为——判断值不值得看,回 Deja 按
|
|
73
|
+
* session 读原文,图本体不进库。
|
|
74
|
+
*/
|
|
75
|
+
sessionImages?: number;
|
|
76
|
+
/**
|
|
77
|
+
* 本 session 里 AI 自己产的图片总数:工具截图、读进来的图片文件、模型生成的图。
|
|
78
|
+
* 实测它是用户贴图的三倍多(5812 vs 1629)。
|
|
79
|
+
*
|
|
80
|
+
* 跟 sessionImages 分开计,因为两者服务的检索意图不是一回事——人找「我发过的
|
|
81
|
+
* 那张截图」要的是前者;agent 回溯「我当时生成的示意图长什么样」「那个页面当时
|
|
82
|
+
* 渲染成什么样」要的是后者。混成一个数,两种意图都答不好;只留前者,则 agent
|
|
83
|
+
* 自己回溯时看不见任何线索。
|
|
84
|
+
*/
|
|
85
|
+
sessionToolImages?: number;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
// ============================================================================
|
|
@@ -649,13 +672,20 @@ export async function drainPendingQueue(
|
|
|
649
672
|
const entryText = extractions[j].l1 || extractions[j].l0;
|
|
650
673
|
const language = detectLang(entryText);
|
|
651
674
|
const fts_text = tokenizeFts(entryText, language);
|
|
652
|
-
|
|
675
|
+
// 2026-08-24 修:回填路径此前直接 store.store(),绕过 buildIngestedEntry,
|
|
676
|
+
// 导致条目缺 boundary/layer/tier(transcript 碎片不降权)。改走同一构造器;
|
|
677
|
+
// 入队时未保留原文件路径,file 用 "pending-queue" 诚实标记回填来源。
|
|
678
|
+
const entry = buildIngestedEntry({
|
|
679
|
+
source: batch[j].scope.split(":")[0],
|
|
680
|
+
scope: batch[j].scope,
|
|
653
681
|
text: entryText,
|
|
654
682
|
vector: vectors[j],
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
683
|
+
extraction: extractions[j],
|
|
684
|
+
file: "pending-queue",
|
|
685
|
+
});
|
|
686
|
+
await store.store({
|
|
687
|
+
...entry,
|
|
688
|
+
category: entry.category as any,
|
|
659
689
|
language,
|
|
660
690
|
fts_text,
|
|
661
691
|
});
|
|
@@ -696,6 +726,10 @@ function buildIngestedEntry(params: {
|
|
|
696
726
|
heading?: string;
|
|
697
727
|
/** Tier 3.1: optional core summary (≤200 chars) for token-efficient context output */
|
|
698
728
|
coreSummary?: string | null;
|
|
729
|
+
/** 本条记忆所在 session 里用户亲手贴的图片总数;无图时不传 */
|
|
730
|
+
sessionImages?: number;
|
|
731
|
+
/** 本条记忆所在 session 里 AI 自己产的图片总数(截图 / 读图 / 生图);无则不传 */
|
|
732
|
+
sessionToolImages?: number;
|
|
699
733
|
}): {
|
|
700
734
|
text: string;
|
|
701
735
|
vector: number[];
|
|
@@ -739,6 +773,8 @@ function buildIngestedEntry(params: {
|
|
|
739
773
|
l1_overview: params.extraction.l1,
|
|
740
774
|
l2_content: params.text,
|
|
741
775
|
...(params.coreSummary ? { core_summary: params.coreSummary } : {}),
|
|
776
|
+
...(params.sessionImages ? { sessionImages: params.sessionImages } : {}),
|
|
777
|
+
...(params.sessionToolImages ? { sessionToolImages: params.sessionToolImages } : {}),
|
|
742
778
|
tier,
|
|
743
779
|
boundary: resolution.boundary,
|
|
744
780
|
...(narrative ? { narrative } : {}),
|
|
@@ -786,16 +822,72 @@ const MARKDOWN_CHUNK_CONFIG: ChunkerConfig = {
|
|
|
786
822
|
// CC Transcript Parser
|
|
787
823
|
// ============================================================================
|
|
788
824
|
|
|
789
|
-
|
|
825
|
+
/**
|
|
826
|
+
* 数一条 transcript 记录里所有的图片信号,**不预设它们是什么用途、在什么位置**。
|
|
827
|
+
*
|
|
828
|
+
* 这是有意的宽口径:AI 产图的形态一直在长(工具截图、读进来的图片文件、模型生图、
|
|
829
|
+
* 写公众号时让它配的插图……),任何"列举有哪几类"的写法都注定漏掉下一类。所以这里
|
|
830
|
+
* 只回答一个问题——「这是不是一张图」,然后由调用方用**补集**去定义 AI 产图:
|
|
831
|
+
* 整条记录里的图 减去 用户在消息里亲手贴的,剩下的全算,不问出身。
|
|
832
|
+
*
|
|
833
|
+
* 判据是「携带图片的块」的三种通用形态,跨 Anthropic / OpenAI / Kimi 都成立:
|
|
834
|
+
* - type 里含 image(image / input_image / image_url / image_generation_* …)
|
|
835
|
+
* - 有 image_url / imageUrl 字段
|
|
836
|
+
* - 有 media_type / mimeType 且以 image/ 开头
|
|
837
|
+
*
|
|
838
|
+
* ⚠️ 这是**信号计数不是精确张数**:一次生图可能同时留下 call 与 end 两条记录,
|
|
839
|
+
* 会被数成 2。这是刻意选的方向——宁可多算,不可漏报,因为它服务的问题是
|
|
840
|
+
* 「这附近有没有图」而不是「精确几张」。
|
|
841
|
+
*/
|
|
842
|
+
function countImageSignals(node: unknown, depth = 0): number {
|
|
843
|
+
if (depth > 10 || node === null || typeof node !== "object") return 0;
|
|
844
|
+
if (Array.isArray(node)) {
|
|
845
|
+
let sum = 0;
|
|
846
|
+
for (const item of node) sum += countImageSignals(item, depth + 1);
|
|
847
|
+
return sum;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const obj = node as Record<string, unknown>;
|
|
851
|
+
const type = obj.type;
|
|
852
|
+
const mediaType = obj.media_type ?? obj.mimeType;
|
|
853
|
+
const isImageBlock =
|
|
854
|
+
(typeof type === "string" && type.includes("image")) ||
|
|
855
|
+
typeof obj.image_url === "string" ||
|
|
856
|
+
(obj.imageUrl !== null && typeof obj.imageUrl === "object") ||
|
|
857
|
+
(typeof mediaType === "string" && mediaType.startsWith("image/"));
|
|
858
|
+
|
|
859
|
+
// 命中即计一次并停止下钻:一个图片块内部不会再嵌另一张独立的图,继续递归
|
|
860
|
+
// 只会把 {type:"image", source:{media_type:"image/png"}} 这种数成两张。
|
|
861
|
+
if (isImageBlock) return 1;
|
|
862
|
+
|
|
863
|
+
let count = 0;
|
|
864
|
+
for (const value of Object.values(obj)) {
|
|
865
|
+
if (value !== null && typeof value === "object") {
|
|
866
|
+
count += countImageSignals(value, depth + 1);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return count;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
790
873
|
const turns: ConversationTurn[] = [];
|
|
791
874
|
const content = readFileSync(filePath, "utf-8");
|
|
792
875
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
793
876
|
|
|
794
877
|
let sessionId = "";
|
|
878
|
+
// 整个 session 里的图片计数,扫完全文后统一盖到每个 turn 上。两类分开数:
|
|
879
|
+
// 用户亲手贴的 vs AI 自己产的(工具截图 / 读图 / 生图)。
|
|
880
|
+
// 数得到是因为本函数本来就逐行读完整个文件,顺带累加不额外花钱。
|
|
881
|
+
let sessionImages = 0;
|
|
882
|
+
let sessionToolImages = 0;
|
|
795
883
|
|
|
796
884
|
for (const line of lines) {
|
|
797
885
|
try {
|
|
798
886
|
const obj = JSON.parse(line);
|
|
887
|
+
// 这一行里所有的图片信号。用户亲手贴的那部分在下面单独精确认,
|
|
888
|
+
// 其余的一律按补集归入 AI 产图——不枚举它是截图、读图、生图还是配图。
|
|
889
|
+
const lineImageSignals = countImageSignals(obj);
|
|
890
|
+
let lineUserImages = 0;
|
|
799
891
|
|
|
800
892
|
if (!sessionId && obj.sessionId) {
|
|
801
893
|
sessionId = obj.sessionId;
|
|
@@ -804,20 +896,39 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
|
804
896
|
if (obj.type === "user" && obj.message) {
|
|
805
897
|
const msg = obj.message;
|
|
806
898
|
let text = "";
|
|
899
|
+
let turnImages = 0;
|
|
807
900
|
if (typeof msg.content === "string") {
|
|
808
901
|
text = msg.content;
|
|
809
902
|
} else if (Array.isArray(msg.content)) {
|
|
810
|
-
//
|
|
903
|
+
// 文本块照旧提取,tool_use / tool_result 仍然整块丢弃(架构决定,见 memory-boundaries)。
|
|
904
|
+
// 图片本体也不进库,只数一下:这里只认顶层的 image block,
|
|
905
|
+
// tool_result 里内嵌的不算——那是工具截图/读图,实测占全部图片九成。
|
|
811
906
|
text = msg.content
|
|
812
907
|
.filter((c: any) => c.type === "text")
|
|
813
908
|
.map((c: any) => c.text || "")
|
|
814
909
|
.join("\n");
|
|
910
|
+
turnImages = msg.content.filter((c: any) => c?.type === "image").length;
|
|
815
911
|
}
|
|
816
|
-
|
|
817
|
-
|
|
912
|
+
sessionImages += turnImages;
|
|
913
|
+
lineUserImages = turnImages;
|
|
914
|
+
|
|
915
|
+
// 只贴图不配字的那一轮,此前会被长度闸整条丢掉——实测占含图轮次的 12.5%,
|
|
916
|
+
// 且丢掉的常是「操作步骤在这里」+ 截图这类正文全在图里的高价值轮次
|
|
917
|
+
// (最极端的样本是 0 字配 7 张图)。给它一个占位正文,让它能跟下一条
|
|
918
|
+
// 助手回复合成 chunk——助手的回复通常描述了图里是什么,那就是定位这些图的文字。
|
|
919
|
+
//
|
|
920
|
+
// 有配文时正文一个字都不改:文字本身已是检索入口,往里塞标记只会稀释 embedding。
|
|
921
|
+
const trimmed = text.trim();
|
|
922
|
+
const body = trimmed.length > 0
|
|
923
|
+
? trimmed
|
|
924
|
+
: turnImages > 0
|
|
925
|
+
? `[图片×${turnImages}]`
|
|
926
|
+
: "";
|
|
927
|
+
|
|
928
|
+
if (body.length > 10 || turnImages > 0) {
|
|
818
929
|
turns.push({
|
|
819
930
|
role: "user",
|
|
820
|
-
text:
|
|
931
|
+
text: body,
|
|
821
932
|
timestamp: obj.timestamp || "",
|
|
822
933
|
sessionId,
|
|
823
934
|
});
|
|
@@ -846,11 +957,26 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
|
|
|
846
957
|
});
|
|
847
958
|
}
|
|
848
959
|
}
|
|
960
|
+
|
|
961
|
+
// 补集:这一行里除用户亲手贴的之外的图,全算 AI 产的,不问出身。
|
|
962
|
+
// 用补集而不是列举,是因为 AI 产图的形态会一直长(生图、截图、读图、
|
|
963
|
+
// 写文章时的配图……),任何枚举都注定漏掉下一类。
|
|
964
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
849
965
|
} catch {
|
|
850
966
|
// Skip malformed lines
|
|
851
967
|
}
|
|
852
968
|
}
|
|
853
969
|
|
|
970
|
+
// 整份 transcript 扫完才知道总数,统一盖到每个 turn 上——这条标记回答的是
|
|
971
|
+
// 「这条记忆所在的 session 里有没有图」,不是「这一轮有没有图」。图所在的那轮
|
|
972
|
+
// 常因正文太短进不了库,只有盖到同 session 的其他轮次上,才可能被搜到。
|
|
973
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
974
|
+
for (const turn of turns) {
|
|
975
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
976
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
854
980
|
return turns;
|
|
855
981
|
}
|
|
856
982
|
|
|
@@ -872,12 +998,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
872
998
|
text: string;
|
|
873
999
|
timestamp: string;
|
|
874
1000
|
sessionId: string;
|
|
1001
|
+
sessionImages?: number;
|
|
1002
|
+
sessionToolImages?: number;
|
|
875
1003
|
}> {
|
|
876
1004
|
const filtered = filterNoiseTurns(turns);
|
|
877
|
-
const chunks: Array<{
|
|
1005
|
+
const chunks: Array<{
|
|
1006
|
+
text: string;
|
|
1007
|
+
timestamp: string;
|
|
1008
|
+
sessionId: string;
|
|
1009
|
+
sessionImages?: number;
|
|
1010
|
+
sessionToolImages?: number;
|
|
1011
|
+
}> = [];
|
|
878
1012
|
|
|
879
1013
|
for (let i = 0; i < filtered.length; i++) {
|
|
880
1014
|
const turn = filtered[i];
|
|
1015
|
+
// 同一 session 切出来的每个 chunk 都带同一个标——它说的是「这个 session 有图」,
|
|
1016
|
+
// 跟这一片本身有没有图无关,这正是 session 粒度要的效果。
|
|
1017
|
+
const imgMark = {
|
|
1018
|
+
...(turn.sessionImages ? { sessionImages: turn.sessionImages } : {}),
|
|
1019
|
+
...(turn.sessionToolImages ? { sessionToolImages: turn.sessionToolImages } : {}),
|
|
1020
|
+
};
|
|
881
1021
|
|
|
882
1022
|
// If this is a user turn followed by an assistant turn, merge them
|
|
883
1023
|
if (turn.role === "user" && i + 1 < filtered.length && filtered[i + 1].role === "assistant") {
|
|
@@ -896,10 +1036,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
896
1036
|
if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
|
|
897
1037
|
const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
|
|
898
1038
|
for (const chunk of chunkResult.chunks) {
|
|
899
|
-
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1039
|
+
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
900
1040
|
}
|
|
901
1041
|
} else {
|
|
902
|
-
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1042
|
+
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
903
1043
|
}
|
|
904
1044
|
}
|
|
905
1045
|
} else {
|
|
@@ -907,6 +1047,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
907
1047
|
text: merged,
|
|
908
1048
|
timestamp: turn.timestamp,
|
|
909
1049
|
sessionId: turn.sessionId,
|
|
1050
|
+
...imgMark,
|
|
910
1051
|
});
|
|
911
1052
|
}
|
|
912
1053
|
|
|
@@ -925,10 +1066,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
925
1066
|
if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
|
|
926
1067
|
const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
|
|
927
1068
|
for (const chunk of chunkResult.chunks) {
|
|
928
|
-
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1069
|
+
chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
929
1070
|
}
|
|
930
1071
|
} else {
|
|
931
|
-
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
|
|
1072
|
+
chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
|
|
932
1073
|
}
|
|
933
1074
|
}
|
|
934
1075
|
} else {
|
|
@@ -936,6 +1077,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
936
1077
|
text,
|
|
937
1078
|
timestamp: turn.timestamp,
|
|
938
1079
|
sessionId: turn.sessionId,
|
|
1080
|
+
...imgMark,
|
|
939
1081
|
});
|
|
940
1082
|
}
|
|
941
1083
|
}
|
|
@@ -959,18 +1101,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
|
|
|
959
1101
|
// Codex Session Parser
|
|
960
1102
|
// ============================================================================
|
|
961
1103
|
|
|
962
|
-
function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
1104
|
+
export function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
963
1105
|
const turns: ConversationTurn[] = [];
|
|
964
1106
|
const content = readFileSync(filePath, "utf-8");
|
|
965
1107
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
966
1108
|
|
|
967
1109
|
let sessionId = "";
|
|
1110
|
+
// 同 parseCCTranscript:两类图片分开计数。
|
|
1111
|
+
// Codex 的图片字段是 input_image(OpenAI 格式),不是 Anthropic 的 image;
|
|
1112
|
+
// 用户贴的在 payload.content 里可以精确认;其余一律按补集归 AI 产图。
|
|
1113
|
+
let sessionImages = 0;
|
|
1114
|
+
let sessionToolImages = 0;
|
|
968
1115
|
|
|
969
1116
|
for (const line of lines) {
|
|
970
1117
|
try {
|
|
971
1118
|
const obj = JSON.parse(line);
|
|
972
1119
|
const payload = obj.payload;
|
|
973
1120
|
const timestamp = obj.timestamp || "";
|
|
1121
|
+
// 同 parseCCTranscript:整行图片信号,减去用户贴的,剩下全归 AI 产
|
|
1122
|
+
const lineImageSignals = countImageSignals(obj);
|
|
1123
|
+
let lineUserImages = 0;
|
|
974
1124
|
|
|
975
1125
|
if (obj.type === "session_meta" && payload?.id) {
|
|
976
1126
|
sessionId = payload.id;
|
|
@@ -982,6 +1132,13 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
|
982
1132
|
const content = payload.content;
|
|
983
1133
|
|
|
984
1134
|
if (Array.isArray(content)) {
|
|
1135
|
+
// 只精确认用户消息里的 input_image;Codex 自己生的图 / 读的图挂在
|
|
1136
|
+
// payload.output 等别处,由下面的补集统一接住,这里不去枚举它们。
|
|
1137
|
+
if (role === "user") {
|
|
1138
|
+
const own = content.filter((c: any) => c?.type === "input_image").length;
|
|
1139
|
+
sessionImages += own;
|
|
1140
|
+
lineUserImages += own;
|
|
1141
|
+
}
|
|
985
1142
|
for (const c of content) {
|
|
986
1143
|
if (c.type === "input_text" && c.text && c.text.length > 10) {
|
|
987
1144
|
// Skip system/developer prompts (usually very long instructions)
|
|
@@ -1023,11 +1180,21 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
|
|
|
1023
1180
|
}
|
|
1024
1181
|
}
|
|
1025
1182
|
}
|
|
1183
|
+
|
|
1184
|
+
// 补集:除用户亲手贴的之外,这一行里的图全算 AI 产的,不问出身。
|
|
1185
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
1026
1186
|
} catch {
|
|
1027
1187
|
// Skip malformed lines
|
|
1028
1188
|
}
|
|
1029
1189
|
}
|
|
1030
1190
|
|
|
1191
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
1192
|
+
for (const turn of turns) {
|
|
1193
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
1194
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1031
1198
|
return turns;
|
|
1032
1199
|
}
|
|
1033
1200
|
|
|
@@ -1164,6 +1331,8 @@ export async function ingestCodexSessions(
|
|
|
1164
1331
|
sessionId: chunk.sessionId,
|
|
1165
1332
|
file: basename(filePath),
|
|
1166
1333
|
coreSummary: coreSummaries[j],
|
|
1334
|
+
sessionImages: chunk.sessionImages,
|
|
1335
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1167
1336
|
}));
|
|
1168
1337
|
}
|
|
1169
1338
|
}
|
|
@@ -1205,7 +1374,7 @@ export async function ingestCodexSessions(
|
|
|
1205
1374
|
// context.append_loop_event content.part → 助手文本(part.type==="text";think 跳过)
|
|
1206
1375
|
// 其余类型(llm.tools_snapshot / mcp.tools_discovered / config.update / llm.request /
|
|
1207
1376
|
// usage.record / permission.* 等,约占 86% 体积)是运行事件,一律跳过。
|
|
1208
|
-
function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
1377
|
+
export function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
1209
1378
|
const turns: ConversationTurn[] = [];
|
|
1210
1379
|
const content = readFileSync(filePath, "utf-8");
|
|
1211
1380
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
@@ -1215,6 +1384,11 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1215
1384
|
const pathParts = filePath.split("/");
|
|
1216
1385
|
const rawId = pathParts.length >= 4 ? pathParts[pathParts.length - 4] : basename(filePath, ".jsonl");
|
|
1217
1386
|
const sessionId = rawId.replace(/^session_/, "");
|
|
1387
|
+
// 同 parseCCTranscript:两类图片分开计数。
|
|
1388
|
+
// Kimi 的图片字段是 image_url;用户贴的在 turn.prompt / context.append_message,
|
|
1389
|
+
// AI 产的在 context.append_loop_event 的 tool.result 里。
|
|
1390
|
+
let sessionImages = 0;
|
|
1391
|
+
let sessionToolImages = 0;
|
|
1218
1392
|
|
|
1219
1393
|
const pushTurn = (role: "user" | "assistant", text: string, timestamp: string) => {
|
|
1220
1394
|
const trimmed = text.trim();
|
|
@@ -1231,6 +1405,23 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1231
1405
|
const obj = JSON.parse(line);
|
|
1232
1406
|
const timestamp = typeof obj.time === "number" ? new Date(obj.time).toISOString() : "";
|
|
1233
1407
|
|
|
1408
|
+
// 图片统计在这里一次算完,不放进下面的分支——那些分支各自 continue,
|
|
1409
|
+
// 放进去会被跳过。用户贴的精确认,其余按补集归 AI 产,不枚举出身。
|
|
1410
|
+
{
|
|
1411
|
+
const lineImageSignals = countImageSignals(obj);
|
|
1412
|
+
let lineUserImages = 0;
|
|
1413
|
+
if (obj.type === "turn.prompt" && obj.origin?.kind === "user" && Array.isArray(obj.input)) {
|
|
1414
|
+
lineUserImages = obj.input.filter((c: any) => c?.type === "image_url").length;
|
|
1415
|
+
} else if (obj.type === "context.append_message") {
|
|
1416
|
+
const m = obj.message;
|
|
1417
|
+
if (m?.role === "user" && m?.origin?.kind === "user" && Array.isArray(m.content)) {
|
|
1418
|
+
lineUserImages = m.content.filter((c: any) => c?.type === "image_url").length;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
sessionImages += lineUserImages;
|
|
1422
|
+
sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1234
1425
|
if (obj.type === "turn.prompt") {
|
|
1235
1426
|
if (obj.origin?.kind !== "user") continue;
|
|
1236
1427
|
const parts = Array.isArray(obj.input) ? obj.input : [];
|
|
@@ -1271,6 +1462,13 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
|
|
|
1271
1462
|
}
|
|
1272
1463
|
}
|
|
1273
1464
|
|
|
1465
|
+
if (sessionImages > 0 || sessionToolImages > 0) {
|
|
1466
|
+
for (const turn of turns) {
|
|
1467
|
+
if (sessionImages > 0) turn.sessionImages = sessionImages;
|
|
1468
|
+
if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1274
1472
|
return turns;
|
|
1275
1473
|
}
|
|
1276
1474
|
|
|
@@ -1434,6 +1632,8 @@ export async function ingestKimiSessions(
|
|
|
1434
1632
|
sessionId: chunk.sessionId,
|
|
1435
1633
|
file: basename(filePath),
|
|
1436
1634
|
coreSummary: coreSummaries[j],
|
|
1635
|
+
sessionImages: chunk.sessionImages,
|
|
1636
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1437
1637
|
}));
|
|
1438
1638
|
}
|
|
1439
1639
|
}
|
|
@@ -1731,10 +1931,24 @@ export async function ingestCCTranscripts(
|
|
|
1731
1931
|
store: MemoryStore,
|
|
1732
1932
|
embedder: Embedder,
|
|
1733
1933
|
sourcePath: string,
|
|
1734
|
-
options: {
|
|
1934
|
+
options: {
|
|
1935
|
+
limit?: number;
|
|
1936
|
+
verbose?: boolean;
|
|
1937
|
+
noDedup?: boolean;
|
|
1938
|
+
llm?: LLMClient | null;
|
|
1939
|
+
recentHours?: number;
|
|
1940
|
+
/**
|
|
1941
|
+
* scope 前缀与来源标签。默认 "cc" —— 这个默认值是承重的:
|
|
1942
|
+
* memory-boundaries.ts 的 TRANSCRIPT_SCOPE_PREFIXES 靠前缀把会话碎片降权到
|
|
1943
|
+
* evidence 层,前缀写错等于让某个端的 shell 输出被当成"用户说过的话"。
|
|
1944
|
+
* 新增来源时必须同时在那份清单里登记,别只在这里传个新字符串。
|
|
1945
|
+
*/
|
|
1946
|
+
scopePrefix?: string;
|
|
1947
|
+
} = {},
|
|
1735
1948
|
): Promise<IngestResult> {
|
|
1949
|
+
const scopePrefix = options.scopePrefix ?? "cc";
|
|
1736
1950
|
const result: IngestResult = {
|
|
1737
|
-
source:
|
|
1951
|
+
source: scopePrefix,
|
|
1738
1952
|
filesProcessed: 0,
|
|
1739
1953
|
chunksIngested: 0,
|
|
1740
1954
|
chunksSkipped: 0,
|
|
@@ -1744,6 +1958,12 @@ export async function ingestCCTranscripts(
|
|
|
1744
1958
|
};
|
|
1745
1959
|
|
|
1746
1960
|
const dir = expandHome(sourcePath);
|
|
1961
|
+
// session-digest.py 摘要调用留下的会话记录不 ingest(2026-08-24 审计:这类
|
|
1962
|
+
// transcript 是原始会话 8000 字符采样的副本,ingest 后 scope/metadata 指向
|
|
1963
|
+
// 摘要任务自身 sid——内容是真的、坐标是假的,已产生 1,950 条 evidence 污染)。
|
|
1964
|
+
if (dir.includes("session-digest")) {
|
|
1965
|
+
return result;
|
|
1966
|
+
}
|
|
1747
1967
|
if (!existsSync(dir)) {
|
|
1748
1968
|
result.errors.push(`Directory not found: ${dir}`);
|
|
1749
1969
|
return result;
|
|
@@ -1838,7 +2058,7 @@ export async function ingestCCTranscripts(
|
|
|
1838
2058
|
// Without LLM: queue raw chunks for later extraction instead of storing garbage
|
|
1839
2059
|
if (!options.llm) {
|
|
1840
2060
|
queueForLaterExtraction(
|
|
1841
|
-
dedupedChunks.map(c => ({ text: c.text, scope:
|
|
2061
|
+
dedupedChunks.map(c => ({ text: c.text, scope: `${scopePrefix}:${c.sessionId.slice(0, 8)}` }))
|
|
1842
2062
|
);
|
|
1843
2063
|
result.chunksSkipped += dedupedTexts.length;
|
|
1844
2064
|
continue;
|
|
@@ -1853,14 +2073,20 @@ export async function ingestCCTranscripts(
|
|
|
1853
2073
|
const chunk = dedupedChunks[j];
|
|
1854
2074
|
const ext = extractions[j];
|
|
1855
2075
|
toStore.push(buildIngestedEntry({
|
|
1856
|
-
|
|
1857
|
-
|
|
2076
|
+
// 必须跟 scope 前缀一致:memory-boundaries 有两道闸,isTranscriptScope 看 scope、
|
|
2077
|
+
// isTranscriptSource 看这个字段。写死 "cc" 时降权仍然生效(cc 也在清单里),
|
|
2078
|
+
// 所以错了不会报错、只会让「按来源过滤」静默失真——2026-08-20 接 minis 源时漏改这处,
|
|
2079
|
+
// 库里出现 10 条 scope=minis: 而 source=cc 的记录。
|
|
2080
|
+
source: scopePrefix,
|
|
2081
|
+
scope: `${scopePrefix}:${chunk.sessionId.slice(0, 8)}`,
|
|
1858
2082
|
text: dedupedTexts[j],
|
|
1859
2083
|
vector: dedupedVectors[j],
|
|
1860
2084
|
extraction: ext,
|
|
1861
2085
|
sessionId: chunk.sessionId,
|
|
1862
2086
|
file,
|
|
1863
2087
|
coreSummary: coreSummaries[j],
|
|
2088
|
+
sessionImages: chunk.sessionImages,
|
|
2089
|
+
sessionToolImages: chunk.sessionToolImages,
|
|
1864
2090
|
}));
|
|
1865
2091
|
}
|
|
1866
2092
|
|
package/src/kg-extractor.ts
CHANGED
|
@@ -123,6 +123,23 @@ export class KGExtractor {
|
|
|
123
123
|
this.kgStore = config.kgStore;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Drop this memory's contribution to the graph.
|
|
128
|
+
*
|
|
129
|
+
* Triple ids are `sha256(scope + subject + predicate + object)` — derived from
|
|
130
|
+
* content, not from the source memory. So when a memory's text is replaced, the
|
|
131
|
+
* new text yields brand-new ids and the edges from the OLD text stay behind
|
|
132
|
+
* forever, competing with the new ones during retrieval. Callers that overwrite
|
|
133
|
+
* a memory's content must purge first, then re-extract.
|
|
134
|
+
*
|
|
135
|
+
* Delegates to KGStore.deleteBySource, which is reference-counted: an edge that
|
|
136
|
+
* other memories still attest to only loses this one source (mention_count -1);
|
|
137
|
+
* the row itself is deleted only when the last piece of evidence goes away.
|
|
138
|
+
*/
|
|
139
|
+
async purgeForMemory(memoryId: string): Promise<void> {
|
|
140
|
+
await this.kgStore.deleteBySource(memoryId);
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* Extract triples from text and persist to KG store.
|
|
128
145
|
* Returns the number of triples stored.
|
package/src/llm-client.ts
CHANGED
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
import OpenAI from "openai";
|
|
17
17
|
import { logInfo } from "./stderr-log.js";
|
|
18
18
|
import { isEmotionScoringEnabled } from "./memory-schema.js";
|
|
19
|
+
import { synthesisModel } from "./env-config.js";
|
|
20
|
+
import {
|
|
21
|
+
CLUSTER_INSIGHT_PROMPT,
|
|
22
|
+
CLUSTER_PATTERN_PROMPT,
|
|
23
|
+
allocateClusterBudget,
|
|
24
|
+
numberClusterTexts,
|
|
25
|
+
parseSynthesisJson,
|
|
26
|
+
validateSynthesis,
|
|
27
|
+
type SynthesisVerdict,
|
|
28
|
+
} from "./synthesis-contract.js";
|
|
29
|
+
|
|
30
|
+
/** dream 合成单次调用的超时。离线批处理,宽松换稳定,见 `runSynthesis` 处的注释。 */
|
|
31
|
+
const SYNTHESIS_TIMEOUT_MS = 60_000;
|
|
19
32
|
|
|
20
33
|
// ============================================================================
|
|
21
34
|
// LME-9: Circuit Breaker — LLM 降级策略
|
|
@@ -691,31 +704,64 @@ export class LLMClient {
|
|
|
691
704
|
}
|
|
692
705
|
}
|
|
693
706
|
|
|
707
|
+
// --------------------------------------------------------------------------
|
|
708
|
+
// dream 合成(契约见 synthesis-contract.ts)
|
|
709
|
+
//
|
|
710
|
+
// 这两个方法一起取代了原来的「`generateL0` 当 insight 用 + `extractPattern` 找性格
|
|
711
|
+
// 模式」。三点结构性差别,不是提示词微调:
|
|
712
|
+
// 1. 有弃权路径 —— 炼不出结论时返回 `abstained`,不再硬凑一句进库;
|
|
713
|
+
// 2. 强制 evidence 编号 —— 说不出出处的结论不写库;
|
|
714
|
+
// 3. 输入按成员等额分配预算 —— 不再让簇的前 2,000 字吃光整个上下文。
|
|
715
|
+
// 三者都在 `SynthesisVerdict` 里有形状,调用方据此分开计数,不再退化成一个 null。
|
|
716
|
+
// --------------------------------------------------------------------------
|
|
717
|
+
|
|
694
718
|
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
* @returns Pattern text if found, null if no pattern or LLM failure.
|
|
719
|
+
* 从一簇相关记忆里提炼一条**可复用结论**(规则/因果/决策/踩坑/判断转折)。
|
|
720
|
+
* 提炼不出来返回 `abstained` —— 这条路径是新的,老 `generateL0` 只会在 LLM 失败时
|
|
721
|
+
* 不产出,「这簇不值得留」从来没有表达方式。
|
|
700
722
|
*/
|
|
701
|
-
async
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
723
|
+
async synthesizeClusterInsight(clusterTexts: string[]): Promise<SynthesisVerdict> {
|
|
724
|
+
return this.runSynthesis(clusterTexts, CLUSTER_INSIGHT_PROMPT, "conclusion", 1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 从一簇相关记忆里提炼**跨条目才成立**的结论。
|
|
729
|
+
* evidence ≥2 是定义性要求:只有一条支撑的东西按定义不是跨条目模式。
|
|
730
|
+
*/
|
|
731
|
+
async synthesizeClusterPattern(clusterTexts: string[]): Promise<SynthesisVerdict> {
|
|
732
|
+
if (clusterTexts.length < 3) return { status: "abstained" };
|
|
733
|
+
return this.runSynthesis(clusterTexts, CLUSTER_PATTERN_PROMPT, "pattern", 2);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private async runSynthesis(
|
|
737
|
+
clusterTexts: string[],
|
|
738
|
+
system: string,
|
|
739
|
+
textField: string,
|
|
740
|
+
minEvidence: number,
|
|
741
|
+
): Promise<SynthesisVerdict> {
|
|
742
|
+
if (clusterTexts.length === 0) return { status: "abstained" };
|
|
743
|
+
|
|
744
|
+
const budgeted = allocateClusterBudget(clusterTexts);
|
|
745
|
+
const user = `记忆条目(共 ${clusterTexts.length} 条):\n${numberClusterTexts(budgeted)}`;
|
|
746
|
+
// max_tokens 放宽到 400:默认的 500 是给整段回复的,而这里只要一个小 JSON;
|
|
747
|
+
// 给足余量是为了避免 evidence 数组被截断——截断的 JSON 会整条判 unparsable,
|
|
748
|
+
// 一条本可用的结论就这么丢了。
|
|
749
|
+
// max_tokens 沿用默认 500,不另设:2026-08-23 实测 60 次合成调用 finish_reason
|
|
750
|
+
// 全为 stop、completion_tokens 最大仅 132,输出侧根本够不到上限。
|
|
751
|
+
const raw = await this.chatRaw(system, user, {
|
|
752
|
+
model: synthesisModel(),
|
|
753
|
+
// 默认 15s 是给 2,000 字输入定的。等额预算后单次输入可达 12,000 字,更强的
|
|
754
|
+
// 合成模型也更慢;沿用 15s 会把「模型正在正常工作」误判成失败,而 dream 是
|
|
755
|
+
// 离线批处理,等一会儿不影响任何人。
|
|
756
|
+
timeoutMs: SYNTHESIS_TIMEOUT_MS,
|
|
757
|
+
});
|
|
716
758
|
|
|
717
|
-
|
|
718
|
-
|
|
759
|
+
return validateSynthesis({
|
|
760
|
+
parsed: parseSynthesisJson(raw),
|
|
761
|
+
textField,
|
|
762
|
+
memberCount: clusterTexts.length,
|
|
763
|
+
minEvidence,
|
|
764
|
+
});
|
|
719
765
|
}
|
|
720
766
|
|
|
721
767
|
/**
|
|
@@ -843,24 +889,39 @@ export class LLMClient {
|
|
|
843
889
|
// --------------------------------------------------------------------------
|
|
844
890
|
|
|
845
891
|
private async chat(system: string, user: string): Promise<string | null> {
|
|
892
|
+
return this.chatRaw(system, user);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* `chat()` 的可调版:允许按调用点覆盖模型与 token 上限,其余行为(熔断、超时、
|
|
897
|
+
* 温度、异常语义)完全一致。
|
|
898
|
+
*
|
|
899
|
+
* 加这一层而不是给 `chat()` 加参数,是为了让「默认调用」的形状一个字不变 ——
|
|
900
|
+
* 现有十来个调用点不该因为合成这一处的需要而被动改签名。
|
|
901
|
+
*/
|
|
902
|
+
private async chatRaw(
|
|
903
|
+
system: string,
|
|
904
|
+
user: string,
|
|
905
|
+
opts?: { model?: string; maxTokens?: number; timeoutMs?: number },
|
|
906
|
+
): Promise<string | null> {
|
|
846
907
|
// LME-9: Circuit breaker — short-circuit when LLM is down
|
|
847
908
|
if (!this.breaker.canAttempt()) {
|
|
848
909
|
return null;
|
|
849
910
|
}
|
|
850
911
|
|
|
851
912
|
const controller = new AbortController();
|
|
852
|
-
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
913
|
+
const timeout = setTimeout(() => controller.abort(), opts?.timeoutMs ?? this.timeoutMs);
|
|
853
914
|
|
|
854
915
|
try {
|
|
855
916
|
const response = await this.client.chat.completions.create(
|
|
856
917
|
{
|
|
857
|
-
model: this.model,
|
|
918
|
+
model: opts?.model || this.model,
|
|
858
919
|
messages: [
|
|
859
920
|
{ role: "system", content: system },
|
|
860
921
|
{ role: "user", content: user },
|
|
861
922
|
],
|
|
862
923
|
temperature: this.temperature,
|
|
863
|
-
max_tokens: 500,
|
|
924
|
+
max_tokens: opts?.maxTokens ?? 500,
|
|
864
925
|
},
|
|
865
926
|
{ signal: controller.signal },
|
|
866
927
|
);
|