recallnest 3.0.1 → 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 +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- 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 +10 -2
- 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 +434 -68
- 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.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -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 +69 -104
- package/src/kg-contradiction.ts +753 -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 +276 -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 +3 -4
- 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 +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- 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/memory-lint.ts
CHANGED
|
@@ -185,7 +185,7 @@ function groupEntries(entries: MemoryEntry[]): Map<string, MemoryEntry[]> {
|
|
|
185
185
|
// Check 1: Contradictions
|
|
186
186
|
// ---------------------------------------------------------------------------
|
|
187
187
|
|
|
188
|
-
function findContradictions(entries: MemoryEntry[]): LintFinding[] {
|
|
188
|
+
export function findContradictions(entries: MemoryEntry[]): LintFinding[] {
|
|
189
189
|
const findings: LintFinding[] = [];
|
|
190
190
|
|
|
191
191
|
// Only check merge-type categories where contradictions are meaningful
|
package/src/memory-output.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseNarrative } from "./narrative-schema.js";
|
|
|
8
8
|
import { getConfidence, getConfidenceMetadata } from "./confidence-tracker.js";
|
|
9
9
|
import { estimateTokens } from "./context-collapse-renderer.js";
|
|
10
10
|
import { evaluateEntryFreshness, createFreshnessCache } from "./freshness.js";
|
|
11
|
+
import { fullTextScoreThreshold, searchFirstScreen } from "./env-config.js";
|
|
11
12
|
|
|
12
13
|
interface MemoryMetadata {
|
|
13
14
|
source?: string;
|
|
@@ -115,6 +116,49 @@ function formatSessionImages(result: RetrievalResult): string | null {
|
|
|
115
116
|
return `${parts.join(", ")} in this session · read sess=${sess}`;
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
interface DistilledSource {
|
|
120
|
+
/** 原会话日期(`date:` 标签),不是写入日——批量回填的行两者能差出半年。 */
|
|
121
|
+
date: string | null;
|
|
122
|
+
/** 写入时存在行上的原文 anchor;可能是当时助手说的,不一定是用户的话。 */
|
|
123
|
+
text: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 批量提炼行(pivot-apply 从历史会话回填,2026-08-04/05 共 1,516 条):表里那句是转述,不是原话。
|
|
128
|
+
*
|
|
129
|
+
* 这批提炼句没逐条核对过——2026-09-11 抽 20 条对照各自存的原文,8 条有实质走样(丢限定词、
|
|
130
|
+
* 一次性要求记成长期规则、把当时助手的话记成用户偏好……)。原文 anchor 本来就存在行上,
|
|
131
|
+
* 读取时亮出来,比事后清洗存量便宜,也能罩住以后同一条路写进来的行。
|
|
132
|
+
*
|
|
133
|
+
* 只认 pivot-apply 标签:当场记录(manual / agent)的 anchor 语义不同,不在这里出。
|
|
134
|
+
* 不是批量行时返回 null,调用方据此什么都不输出——不给别的记忆添噪音。
|
|
135
|
+
*/
|
|
136
|
+
function getDistilledSource(result: RetrievalResult): DistilledSource | null {
|
|
137
|
+
const meta = parseMetadata(result.entry);
|
|
138
|
+
const tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
139
|
+
if (!tags.includes("pivot-apply")) return null;
|
|
140
|
+
const dateTag = tags.find((tag) => tag.startsWith("date:"));
|
|
141
|
+
return {
|
|
142
|
+
date: dateTag ? dateTag.slice("date:".length) : null,
|
|
143
|
+
text: typeof meta.anchor === "string" ? meta.anchor : "",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DISTILLED_ORIGIN_MAX_LEN = 160;
|
|
148
|
+
|
|
149
|
+
// 措辞用英文,与 prov / imgs / fresh 行保持一致(这个输出面是英文的)。
|
|
150
|
+
const DISTILLED_NOTE =
|
|
151
|
+
"rows with an orig line were batch-distilled from old sessions: the snippet is a paraphrase, " +
|
|
152
|
+
"orig is the source text (it may be the assistant's words, not the user's) — trust orig";
|
|
153
|
+
|
|
154
|
+
function formatDistilledOrigin(source: DistilledSource): string {
|
|
155
|
+
const when = source.date ? `(${source.date} session) ` : "";
|
|
156
|
+
const text = source.text
|
|
157
|
+
? cleanSnippet(source.text, DISTILLED_ORIGIN_MAX_LEN)
|
|
158
|
+
: "(source text missing)";
|
|
159
|
+
return `${when}${text}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
118
162
|
function getProvenanceSummary(result: RetrievalResult): string {
|
|
119
163
|
const provenance = extractMemoryProvenance({
|
|
120
164
|
scope: result.entry.scope,
|
|
@@ -205,6 +249,7 @@ function getRetrievalPath(result: RetrievalResult): string {
|
|
|
205
249
|
if (result.sources.vector) parts.push("vector");
|
|
206
250
|
if (result.sources.bm25) parts.push("bm25");
|
|
207
251
|
if (result.sources.reranked) parts.push("reranked");
|
|
252
|
+
if (result.sources.trigger) parts.push("trigger");
|
|
208
253
|
if (result.sources.narrativeSibling) parts.push("narrative");
|
|
209
254
|
return parts.join("+") || "direct";
|
|
210
255
|
}
|
|
@@ -256,8 +301,12 @@ function pickBestSnippet(query: string, text: string): string {
|
|
|
256
301
|
* adaptive 专用 query-aware snippet:取匹配词周围窗口(而非整句/开头),保证匹配证据可见。
|
|
257
302
|
* 解决 pickBestSnippet 的两个退化(Codex 审点4 C 版 P2):① 短缩写 query(如 "CI")
|
|
258
303
|
* extractTerms 提取不到词 → 退回开头;② 匹配在超长句/log 行后段 → cleanSnippet 从头截断丢匹配。
|
|
259
|
-
*
|
|
304
|
+
* adaptive 档与默认第一屏(`formatFullTextResults` 里超预算降级的那条路)共用;旧表格(legacy)与 explain
|
|
305
|
+
* 走的是 pickBestSnippet,不经这里。
|
|
260
306
|
*/
|
|
307
|
+
const ADAPTIVE_SNIPPET_BEFORE = 60;
|
|
308
|
+
const ADAPTIVE_SNIPPET_AFTER = 180;
|
|
309
|
+
|
|
261
310
|
function adaptiveSnippet(query: string, text: string): string {
|
|
262
311
|
const clean = text.replace(/\s+/g, " ").trim();
|
|
263
312
|
const lower = clean.toLowerCase();
|
|
@@ -285,10 +334,8 @@ function adaptiveSnippet(query: string, text: string): string {
|
|
|
285
334
|
}
|
|
286
335
|
if (matchIdx < 0) return pickBestSnippet(query, text); // 完全无匹配 → 退回整句最佳
|
|
287
336
|
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
const start = Math.max(0, matchIdx - WINDOW_BEFORE);
|
|
291
|
-
const end = Math.min(clean.length, matchIdx + WINDOW_AFTER);
|
|
337
|
+
const start = Math.max(0, matchIdx - ADAPTIVE_SNIPPET_BEFORE);
|
|
338
|
+
const end = Math.min(clean.length, matchIdx + ADAPTIVE_SNIPPET_AFTER);
|
|
292
339
|
let snip = clean.slice(start, end).trim();
|
|
293
340
|
if (start > 0) snip = "…" + snip;
|
|
294
341
|
if (end < clean.length) snip = snip + "…";
|
|
@@ -336,6 +383,12 @@ function buildWhyMatched(query: string, result: RetrievalResult): string {
|
|
|
336
383
|
reasons.push("reranked");
|
|
337
384
|
}
|
|
338
385
|
|
|
386
|
+
if (result.sources.trigger) {
|
|
387
|
+
// T-Mem:说清是哪句预演问法把它捞出来的(只在 explain 露面,不进 context)
|
|
388
|
+
const t = result.sources.trigger;
|
|
389
|
+
reasons.push(`trigger「${t.text}」cos=${t.cosine.toFixed(2)}${t.admittedBy === "soft" ? ` overlap=${(t.overlap ?? 0).toFixed(2)}` : ""}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
339
392
|
if (matchedTerms.length > 0) {
|
|
340
393
|
reasons.push(`terms:${matchedTerms.slice(0, 3).join(",")}`);
|
|
341
394
|
}
|
|
@@ -384,14 +437,25 @@ export function formatBriefResults(
|
|
|
384
437
|
): string {
|
|
385
438
|
if (results.length === 0) return "No results found.";
|
|
386
439
|
const freshnessCache = createFreshnessCache();
|
|
387
|
-
|
|
440
|
+
// brief 只标不附原文:它就是为省 token 选的档,原文会让它长一倍。
|
|
441
|
+
const distilled = results.map(getDistilledSource);
|
|
442
|
+
const lines = [
|
|
443
|
+
`Query: ${context.query}`,
|
|
444
|
+
`Hits: ${results.length}`,
|
|
445
|
+
...(distilled.some(Boolean)
|
|
446
|
+
? ["Note: [distilled] rows paraphrase old sessions — use detail_level=normal to see the source text"]
|
|
447
|
+
: []),
|
|
448
|
+
"",
|
|
449
|
+
];
|
|
388
450
|
for (let i = 0; i < results.length; i++) {
|
|
389
451
|
const r = results[i];
|
|
390
452
|
const excerpt = extractBriefExcerpt(r);
|
|
453
|
+
const orig = distilled[i] ?? null;
|
|
454
|
+
const distilledTag = orig ? ` [distilled${orig.date ? `, ${orig.date} session` : ""}]` : "";
|
|
391
455
|
// Freshness suffix: only for memories that declared dependsOn (opt-in, cheap check).
|
|
392
456
|
const fresh = evaluateEntryFreshness(r.entry.metadata, freshnessCache);
|
|
393
457
|
const freshTag = fresh ? ` [fresh:${fresh}]` : "";
|
|
394
|
-
lines.push(`#${i + 1} ${r.entry.id.slice(0, 8)} ${(r.score * 100).toFixed(1)}% — ${excerpt}${freshTag}`);
|
|
458
|
+
lines.push(`#${i + 1} ${r.entry.id.slice(0, 8)} ${(r.score * 100).toFixed(1)}% — ${excerpt}${distilledTag}${freshTag}`);
|
|
395
459
|
}
|
|
396
460
|
return lines.join("\n");
|
|
397
461
|
}
|
|
@@ -403,10 +467,12 @@ export function formatFullResults(
|
|
|
403
467
|
if (results.length === 0) return "No results found.";
|
|
404
468
|
|
|
405
469
|
const freshnessCache = createFreshnessCache();
|
|
470
|
+
const distilled = results.map(getDistilledSource);
|
|
406
471
|
const lines = [
|
|
407
472
|
`Query : ${context.query}`,
|
|
408
473
|
`Profile : ${context.profile}`,
|
|
409
474
|
`Hits : ${results.length}`,
|
|
475
|
+
...(distilled.some(Boolean) ? [`Note : ${DISTILLED_NOTE}`] : []),
|
|
410
476
|
"",
|
|
411
477
|
"# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
|
|
412
478
|
"-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
|
|
@@ -418,6 +484,8 @@ export function formatFullResults(
|
|
|
418
484
|
lines.push(` prov : ${getProvenanceSummary(results[i])}`);
|
|
419
485
|
const imgs = formatSessionImages(results[i]);
|
|
420
486
|
if (imgs) lines.push(` imgs : ${imgs}`);
|
|
487
|
+
const orig = distilled[i] ?? null;
|
|
488
|
+
if (orig) lines.push(` orig : ${formatDistilledOrigin(orig)}`);
|
|
421
489
|
// Full mode: append metadata details
|
|
422
490
|
const meta = parseMetadata(results[i].entry);
|
|
423
491
|
const evolution = typeof meta.evolutionStatus === "string" ? meta.evolutionStatus : "-";
|
|
@@ -448,17 +516,23 @@ export function formatFullResults(
|
|
|
448
516
|
return lines.join("\n");
|
|
449
517
|
}
|
|
450
518
|
|
|
451
|
-
|
|
519
|
+
/**
|
|
520
|
+
* 2026-10-05 之前的默认第一屏:表格 + 每条 120 字片段。函数体一个字没动,只是改了名。
|
|
521
|
+
* 现在只在 `RECALLNEST_SEARCH_FIRST_SCREEN=legacy` 时由 `formatSearchResults` 走到(退回开关)。
|
|
522
|
+
*/
|
|
523
|
+
export function formatLegacyTableResults(
|
|
452
524
|
results: RetrievalResult[],
|
|
453
525
|
context: RenderContext,
|
|
454
526
|
): string {
|
|
455
527
|
if (results.length === 0) return "No results found.";
|
|
456
528
|
|
|
457
529
|
const freshnessCache = createFreshnessCache();
|
|
530
|
+
const distilled = results.map(getDistilledSource);
|
|
458
531
|
const lines = [
|
|
459
532
|
`Query : ${context.query}`,
|
|
460
533
|
`Profile : ${context.profile}`,
|
|
461
534
|
`Hits : ${results.length}`,
|
|
535
|
+
...(distilled.some(Boolean) ? [`Note : ${DISTILLED_NOTE}`] : []),
|
|
462
536
|
"",
|
|
463
537
|
"# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
|
|
464
538
|
"-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
|
|
@@ -470,6 +544,8 @@ export function formatSearchResults(
|
|
|
470
544
|
lines.push(` prov : ${getProvenanceSummary(results[i])}`);
|
|
471
545
|
const imgs = formatSessionImages(results[i]);
|
|
472
546
|
if (imgs) lines.push(` imgs : ${imgs}`);
|
|
547
|
+
const orig = distilled[i] ?? null;
|
|
548
|
+
if (orig) lines.push(` orig : ${formatDistilledOrigin(orig)}`);
|
|
473
549
|
// Freshness: only shown for memories that declared dependsOn (opt-in, cheap check).
|
|
474
550
|
const fresh = evaluateEntryFreshness(results[i].entry.metadata, freshnessCache);
|
|
475
551
|
if (fresh) lines.push(` fresh: ${fresh}`);
|
|
@@ -479,7 +555,182 @@ export function formatSearchResults(
|
|
|
479
555
|
}
|
|
480
556
|
|
|
481
557
|
const ADAPTIVE_TOKEN_BUDGET = 8000;
|
|
482
|
-
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 默认第一屏单条正文的封顶(按码点数)。
|
|
561
|
+
*
|
|
562
|
+
* 线是照库里的实际长度定的(2026-10-05 只读快照,138,501 条活跃记忆):手写与提炼条目最长 2,278、
|
|
563
|
+
* 会话切片最长 2,077、记忆文件切片最长 1,523,都在线下,永远给全文;会被截的只有项目文档的大切片
|
|
564
|
+
* (`project:` 7,797 条,中位 2,401、最长 3,990)。封顶防的是一条长文档排第一时把后面几条的预算吃光。
|
|
565
|
+
*/
|
|
566
|
+
const FIRST_SCREEN_ENTRY_CAP = 2400;
|
|
567
|
+
|
|
568
|
+
const DISTILLED_NOTE_FULLTEXT =
|
|
569
|
+
"entries with an orig line are batch-distilled paraphrases of old sessions; " +
|
|
570
|
+
"orig is the source text (it may be the assistant's words, not the user's) — trust orig";
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 条目行末尾的出处:有会话编号就给 `sess=前 8 位`(imgs 行、Deja 回查用的都是它);文件名只在它不是
|
|
574
|
+
* 会话编号的重复时才给——Claude Code 的会话文件叫 `<会话编号>.jsonl`、Codex 的叫 `rollout-<时间>-<会话编号>.jsonl`,
|
|
575
|
+
* 再写一遍每条白占四五十个字。
|
|
576
|
+
*/
|
|
577
|
+
function getOriginLabel(result: RetrievalResult): string[] {
|
|
578
|
+
const meta = parseMetadata(result.entry);
|
|
579
|
+
const sessionId = typeof meta.sessionId === "string" ? meta.sessionId : "";
|
|
580
|
+
const file = getFileLabel(result);
|
|
581
|
+
const parts: string[] = [];
|
|
582
|
+
if (sessionId) parts.push(`sess=${sessionId.slice(0, 8)}`);
|
|
583
|
+
// 要整段会话编号对上才算重复:只对上前 8 位的可能是另有内容的文件名。
|
|
584
|
+
const repeatsSession = sessionId !== "" && file.endsWith(`${sessionId}.jsonl`);
|
|
585
|
+
if (file !== "-" && !repeatsSession) parts.push(file);
|
|
586
|
+
return parts;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* adaptiveSnippet / cleanSnippet 按 UTF-16 下标切,窗口边上可能留下半个代理对。
|
|
593
|
+
* 默认第一屏这条路上把落单的那半个去掉;那两个函数本身不动(adaptive、legacy、explain 的输出不变)。
|
|
594
|
+
*/
|
|
595
|
+
function dropLoneSurrogates(text: string): string {
|
|
596
|
+
return text.replace(LONE_SURROGATE, "");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 片段里属于原文的字数:去掉片段函数在两头补的省略号。 */
|
|
600
|
+
function snippetSourceChars(snippet: string): number {
|
|
601
|
+
return Array.from(snippet.replace(/^…/, "").replace(/(…|\.\.\.)$/, "")).length;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 按码点取开头一段,不把代理对切成两半。`total` 是整条的码点数。 */
|
|
605
|
+
function headByCodePoints(text: string, max: number): { head: string; total: number; truncated: boolean } {
|
|
606
|
+
const points = Array.from(text);
|
|
607
|
+
if (points.length <= max) return { head: text, total: points.length, truncated: false };
|
|
608
|
+
return { head: points.slice(0, max).join(""), total: points.length, truncated: true };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 默认第一屏(2026-10-05 起):按名次给正文全文。
|
|
613
|
+
*
|
|
614
|
+
* 为什么不沿用 120 字片段:同一批 48 条真实查询、同一份命中,三位读者盲判「只看这一屏就够」,
|
|
615
|
+
* 表格 + 120 字是 1 条、全文是 18 条;旧默认屏里正文只占 23%,5 条全文合计的中位字数反而比它短。
|
|
616
|
+
* 为什么不按分数给(adaptive 的做法):分数是融合排序分,只表示这一批里的相对位置,排第一的中位
|
|
617
|
+
* 只有 0.64,43 条非空查询里 31 条一条全文都拿不到。所以这里只看名次,不看分数。
|
|
618
|
+
*
|
|
619
|
+
* 规则:
|
|
620
|
+
* - 按传入顺序(handler 已排好)逐条给正文;单条超过 FIRST_SCREEN_ENTRY_CAP 只给开头那一段。
|
|
621
|
+
* - 给全文的额度是 ADAPTIVE_TOKEN_BUDGET(与 adaptive 同一个数,正文与 orig 行一起算)。放不下的条目降成
|
|
622
|
+
* 匹配词周围的片段,后面更短的条目放得下仍给全文。
|
|
623
|
+
* - 每条命中都列出来,不因预算整条消失——这一点有意不照 adaptive(它超预算会停并报 omitted)。
|
|
624
|
+
* 所以 8000 是「给全文给到哪为止」的线,不是整屏的硬顶:片段、以及整条不比片段窗口(240 字)长的短条目
|
|
625
|
+
* 不受它拦,整屏最多超出「剩下每条一个片段」(limit ≤ 20;20 条都是 2400 个汉字时约 12,000)。
|
|
626
|
+
* 先给所有条目预留片段再分全文的做法试算过:尾部的片段会把排第一的长条目挤成片段,和「按名次给」相反。
|
|
627
|
+
* - search_memory 的 related-scope 附栏各调一次本函数,各有一份额度。
|
|
628
|
+
* - 缩短过的条目各带一行说明:共多少字、给了多少、用哪个编号展开。顶上 Text 行报整屏的全文 / 截断 / 片段条数。
|
|
629
|
+
* - 元数据每条一行。旧表格里的 Tier 与 Retrieval Path 两列不再出,会话文件名换成 `sess=前 8 位`;
|
|
630
|
+
* 这三样 detail_level=full 里还有。
|
|
631
|
+
* - imgs / orig / fresh 三种附加行照旧。
|
|
632
|
+
*/
|
|
633
|
+
export function formatFullTextResults(
|
|
634
|
+
results: RetrievalResult[],
|
|
635
|
+
context: RenderContext,
|
|
636
|
+
): string {
|
|
637
|
+
if (results.length === 0) return "No results found.";
|
|
638
|
+
|
|
639
|
+
const freshnessCache = createFreshnessCache();
|
|
640
|
+
const blocks: string[] = [];
|
|
641
|
+
let tokensUsed = 0;
|
|
642
|
+
let fullCount = 0;
|
|
643
|
+
let cappedCount = 0;
|
|
644
|
+
let snippetCount = 0;
|
|
645
|
+
let anyOrig = false;
|
|
646
|
+
|
|
647
|
+
for (let i = 0; i < results.length; i++) {
|
|
648
|
+
const r = results[i];
|
|
649
|
+
const id = r.entry.id.slice(0, 8);
|
|
650
|
+
// 展开提示给完整编号:8 位前缀在库里有撞的(2026-10-05 快照 138,502 行里 2 组),撞了 memory_drill_down 取不回来。
|
|
651
|
+
const drillHint = `memory_drill_down ${r.entry.id} for the full text`;
|
|
652
|
+
const capped = headByCodePoints(r.entry.text, FIRST_SCREEN_ENTRY_CAP);
|
|
653
|
+
const orig = getDistilledSource(r);
|
|
654
|
+
const origLine = orig ? ` orig : ${dropLoneSurrogates(formatDistilledOrigin(orig))}` : "";
|
|
655
|
+
const origTokens = origLine ? estimateTokens(origLine) : 0;
|
|
656
|
+
|
|
657
|
+
let text = capped.head;
|
|
658
|
+
let cutNote = capped.truncated
|
|
659
|
+
? ` … [truncated: showing ${FIRST_SCREEN_ENTRY_CAP} of ${capped.total} chars · ${drillHint}]`
|
|
660
|
+
: "";
|
|
661
|
+
let tokens = estimateTokens(text) + origTokens;
|
|
662
|
+
// 整条不比一个片段窗口长的条目不降级:降了也省不下字,只会多出一行「给了片段」的说明。
|
|
663
|
+
const worthSnipping = capped.total > ADAPTIVE_SNIPPET_BEFORE + ADAPTIVE_SNIPPET_AFTER;
|
|
664
|
+
if (worthSnipping && tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) {
|
|
665
|
+
text = dropLoneSurrogates(adaptiveSnippet(context.query, r.entry.text));
|
|
666
|
+
cutNote = ` [snippet: token budget reached · showing ${snippetSourceChars(text)} of ${capped.total} chars · ${drillHint}]`;
|
|
667
|
+
tokens = estimateTokens(text) + origTokens;
|
|
668
|
+
snippetCount++;
|
|
669
|
+
} else if (capped.truncated) {
|
|
670
|
+
cappedCount++;
|
|
671
|
+
} else {
|
|
672
|
+
fullCount++;
|
|
673
|
+
}
|
|
674
|
+
tokensUsed += tokens;
|
|
675
|
+
|
|
676
|
+
const prov = getProvenanceSummary(r);
|
|
677
|
+
const header = [
|
|
678
|
+
id,
|
|
679
|
+
`${(r.score * 100).toFixed(1)}%`,
|
|
680
|
+
`${getDateLabel(r.entry.timestamp)} (${formatAgeLabel(r.entry.timestamp)})`,
|
|
681
|
+
getCategoryLabel(r),
|
|
682
|
+
getSourceLabel(r),
|
|
683
|
+
...(prov !== "-" ? [prov] : []),
|
|
684
|
+
...getOriginLabel(r),
|
|
685
|
+
].join(" · ");
|
|
686
|
+
blocks.push(`[${i + 1}] ${header}`);
|
|
687
|
+
// fresh 管的是「这条还能不能照着用」,放在正文前面;只有声明过 dependsOn 的记忆才有(opt-in)。
|
|
688
|
+
const fresh = evaluateEntryFreshness(r.entry.metadata, freshnessCache);
|
|
689
|
+
if (fresh) blocks.push(` fresh: ${fresh}`);
|
|
690
|
+
blocks.push(text);
|
|
691
|
+
if (cutNote) blocks.push(cutNote);
|
|
692
|
+
if (origLine) {
|
|
693
|
+
blocks.push(origLine);
|
|
694
|
+
anyOrig = true;
|
|
695
|
+
}
|
|
696
|
+
const imgs = formatSessionImages(r);
|
|
697
|
+
if (imgs) blocks.push(` imgs : ${imgs}`);
|
|
698
|
+
blocks.push("");
|
|
699
|
+
}
|
|
700
|
+
// 去掉自己加的最后一个块间空行;不对整份输出 trimEnd,否则最后一条正文自己的结尾空白也会被吃掉。
|
|
701
|
+
blocks.pop();
|
|
702
|
+
|
|
703
|
+
// 全部给全的时候这一行只报条数;有缩短的才把封顶与预算写出来。
|
|
704
|
+
const shortened = cappedCount + snippetCount > 0;
|
|
705
|
+
const breakdown = [
|
|
706
|
+
`${fullCount} in full`,
|
|
707
|
+
...(cappedCount > 0 ? [`${cappedCount} capped (${FIRST_SCREEN_ENTRY_CAP}-char cap)`] : []),
|
|
708
|
+
...(snippetCount > 0 ? [`${snippetCount} snippet (${ADAPTIVE_TOKEN_BUDGET}-token budget)`] : []),
|
|
709
|
+
].join(", ") + (shortened ? " — shortened entries say how much is shown" : "");
|
|
710
|
+
const lines = [
|
|
711
|
+
`Query : ${context.query}`,
|
|
712
|
+
`Profile : ${context.profile}`,
|
|
713
|
+
`Hits : ${results.length}`,
|
|
714
|
+
`Text : ${breakdown}`,
|
|
715
|
+
...(anyOrig ? [`Note : ${DISTILLED_NOTE_FULLTEXT}`] : []),
|
|
716
|
+
"",
|
|
717
|
+
...blocks,
|
|
718
|
+
];
|
|
719
|
+
return lines.join("\n");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* search_memory 默认档(detail_level=normal)、命令行 `search`、本地 UI 共用的入口。
|
|
724
|
+
* 默认出全文那一版;`RECALLNEST_SEARCH_FIRST_SCREEN=legacy` 退回表格 + 120 字片段。
|
|
725
|
+
*/
|
|
726
|
+
export function formatSearchResults(
|
|
727
|
+
results: RetrievalResult[],
|
|
728
|
+
context: RenderContext,
|
|
729
|
+
): string {
|
|
730
|
+
return searchFirstScreen() === "legacy"
|
|
731
|
+
? formatLegacyTableResults(results, context)
|
|
732
|
+
: formatFullTextResults(results, context);
|
|
733
|
+
}
|
|
483
734
|
|
|
484
735
|
/**
|
|
485
736
|
* P-fidelity (点4): adaptive 保真度渲染 — 借鉴 RepoPrompt CE「保真度阶梯」的内核
|
|
@@ -489,7 +740,7 @@ const ADAPTIVE_FULL_SCORE = 0.85;
|
|
|
489
740
|
* collapse 的 l0 floor / 按 score 重排 / query-agnostic fallback 都是为 resume 场景定制,
|
|
490
741
|
* 搬到 search 连续撞三个 P2(隐藏过滤 / 覆盖 highlight 排序 / 丢失匹配证据)。search 的语义
|
|
491
742
|
* 不同——结果是"为什么这条命中",必须 query-aware;故第一版自实现两档:
|
|
492
|
-
* - 高相关(score ≥ 0.85)→ 全文
|
|
743
|
+
* - 高相关(score ≥ fullTextScoreThreshold():legacy 0.85,bounded 流行度下 0.80)→ 全文
|
|
493
744
|
* - 其余 → query-aware snippet(pickBestSnippet,显示匹配证据)
|
|
494
745
|
* 按 handler 传入顺序(normal=score / highlight=contextual)依次填入 token 预算,超预算时
|
|
495
746
|
* 全文降级 snippet、snippet 仍超则停止(剩余计入 omitted)。完整三档保真度阶梯(每条记忆预存
|
|
@@ -504,21 +755,27 @@ export function formatCollapsedResults(
|
|
|
504
755
|
const rendered: string[] = [];
|
|
505
756
|
let tokensUsed = 0;
|
|
506
757
|
let shown = 0;
|
|
758
|
+
let anyOrigShown = false;
|
|
759
|
+
const fullScore = fullTextScoreThreshold();
|
|
507
760
|
|
|
508
761
|
// 保留传入顺序(不重排):handler 已按 normal=score / highlight=contextual 排好。
|
|
509
762
|
for (const r of results) {
|
|
510
|
-
const isFull = r.score >=
|
|
763
|
+
const isFull = r.score >= fullScore;
|
|
511
764
|
let level = isFull ? "FULL" : "SNIP";
|
|
512
765
|
// query-aware:snippet 取匹配词周围窗口、显示匹配证据(search 是"为什么命中")。
|
|
513
766
|
let text = isFull ? r.entry.text : adaptiveSnippet(context.query, r.entry.text);
|
|
514
|
-
|
|
767
|
+
// 批量提炼行附原文。它同样占预算,和正文一起算,别让原文把预算悄悄撑破。
|
|
768
|
+
const orig = getDistilledSource(r);
|
|
769
|
+
const origLine = orig ? `orig: ${formatDistilledOrigin(orig)}` : "";
|
|
770
|
+
const origTokens = origLine ? estimateTokens(origLine) : 0;
|
|
771
|
+
let tokens = estimateTokens(text) + origTokens;
|
|
515
772
|
|
|
516
773
|
if (tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) {
|
|
517
774
|
if (isFull) {
|
|
518
775
|
// 全文超预算 → 降级为 query-aware snippet
|
|
519
776
|
text = adaptiveSnippet(context.query, r.entry.text);
|
|
520
777
|
level = "SNIP";
|
|
521
|
-
tokens = estimateTokens(text);
|
|
778
|
+
tokens = estimateTokens(text) + origTokens;
|
|
522
779
|
}
|
|
523
780
|
if (tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) break; // snippet 仍超 → 停止,剩余计入 omitted
|
|
524
781
|
}
|
|
@@ -530,6 +787,10 @@ export function formatCollapsedResults(
|
|
|
530
787
|
const scorePct = `${(r.score * 100).toFixed(0)}%`;
|
|
531
788
|
rendered.push(`[${level}] ${id} ${scorePct} ${getCategoryLabel(r)} ${getSourceLabel(r)}`);
|
|
532
789
|
rendered.push(text);
|
|
790
|
+
if (origLine) {
|
|
791
|
+
rendered.push(origLine);
|
|
792
|
+
anyOrigShown = true;
|
|
793
|
+
}
|
|
533
794
|
rendered.push("");
|
|
534
795
|
}
|
|
535
796
|
|
|
@@ -537,8 +798,9 @@ export function formatCollapsedResults(
|
|
|
537
798
|
const lines = [
|
|
538
799
|
`Query : ${context.query}`,
|
|
539
800
|
`Profile : ${context.profile}`,
|
|
540
|
-
`Mode : adaptive — full text for score ≥ ${
|
|
801
|
+
`Mode : adaptive — full text for score ≥ ${fullScore}, query-aware snippet otherwise, ${ADAPTIVE_TOKEN_BUDGET}-token budget`,
|
|
541
802
|
`Shown : ${shown} of ${results.length}${omitted > 0 ? ` (${omitted} omitted: token budget)` : ""}`,
|
|
803
|
+
...(anyOrigShown ? [`Note : ${DISTILLED_NOTE}`] : []),
|
|
542
804
|
"",
|
|
543
805
|
...rendered,
|
|
544
806
|
];
|