feiyu-memory 0.3.6 → 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 +75 -6
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client/index.js +14 -42
- package/src/core/config.mjs +22 -0
- package/src/core/consolidate.mjs +160 -127
- package/src/core/handnote.mjs +1 -1
- package/src/core/persona.mjs +109 -170
- package/src/core/recall.mjs +113 -9
- package/src/core/render-diary.mjs +1 -1
- package/src/host/index.mjs +430 -209
package/src/host/index.mjs
CHANGED
|
@@ -40,10 +40,10 @@ import {
|
|
|
40
40
|
// 原来的 evaluateGate 那套关键词闸门拆了(见 turn-stopping 里的注释)。
|
|
41
41
|
// SUPPRESS_RE 留着 —— "别记"是用户的控制,不是启发式。
|
|
42
42
|
import { SUPPRESS_RE } from '../core/gate.mjs';
|
|
43
|
-
import { buildIndex, selectMemories, searchScores, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
43
|
+
import { buildIndex, selectMemories, searchScores, queryHasSubstance, looksLikeWork, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
44
44
|
import { overlap } from '../core/keywords.mjs';
|
|
45
45
|
import { scrub } from '../core/privacy.mjs';
|
|
46
|
-
import { renderMemoryBlock, DEFAULT_BUDGET
|
|
46
|
+
import { renderMemoryBlock, DEFAULT_BUDGET } from '../core/persona.mjs';
|
|
47
47
|
import { calendarDaysBetween, stripTimeWords, softWhen } from '../core/timeline.mjs';
|
|
48
48
|
import { renderDiaryFile } from '../core/render-diary.mjs';
|
|
49
49
|
import { buildJudgeInput, parseVerdict, JUDGE_MAX_TOKENS } from '../core/judge.mjs';
|
|
@@ -56,6 +56,9 @@ import {
|
|
|
56
56
|
makeDiaryId,
|
|
57
57
|
ConsolidationParseError,
|
|
58
58
|
DEFAULT_CONSOLIDATION,
|
|
59
|
+
buildSummaryInput,
|
|
60
|
+
parseSummaryResult,
|
|
61
|
+
applySummaries,
|
|
59
62
|
} from '../core/consolidate.mjs';
|
|
60
63
|
import { resolveConfig, buildSchema, CONFIG_SPEC } from '../core/config.mjs';
|
|
61
64
|
|
|
@@ -425,6 +428,8 @@ export function apply(ctx, rawConfig) {
|
|
|
425
428
|
lastShownTurn: new Map(),
|
|
426
429
|
suppressedUntil: -1,
|
|
427
430
|
queued: false,
|
|
431
|
+
/** 上下文刚被压缩过:下一轮要把最近的事整段重灌一次(免预算) */
|
|
432
|
+
needsRecentReplay: false,
|
|
428
433
|
};
|
|
429
434
|
sessionStates.set(sessionId, s);
|
|
430
435
|
}
|
|
@@ -433,6 +438,184 @@ export function apply(ctx, rawConfig) {
|
|
|
433
438
|
|
|
434
439
|
/* ── 档案加载 ── */
|
|
435
440
|
|
|
441
|
+
/**
|
|
442
|
+
* 给还没 summary 的日记补一句总结。
|
|
443
|
+
*
|
|
444
|
+
* —— 为什么要有 ——
|
|
445
|
+
* summary 是检索的入口(见 pickBySummary)。老条目没有,就只能拿正文前 30 字兜底,
|
|
446
|
+
* 那比 summary 差一截:正文前 30 字是"起手式",不是"他管它叫什么"。
|
|
447
|
+
*
|
|
448
|
+
* —— 为什么后台跑、分批 ——
|
|
449
|
+
* 两百多条是二十几次模型调用。同步跑会把启动卡成几十秒,那比没有 summary 还糟。
|
|
450
|
+
* 所以:每批 10 条、批间隔 1.5 秒,跑完就停;下次启动接着补没补的。
|
|
451
|
+
*
|
|
452
|
+
* —— 为什么要等 llm ——
|
|
453
|
+
* 2026-10-28 踩过的坑:插件 apply 的时候 llm 服务还没挂上,
|
|
454
|
+
* `ctx.get('llm')` 是 undefined,于是这里一声不吭地 return 了 ——
|
|
455
|
+
* 重启了三次,一条 summary 都没补上,而日志里干净得像没事发生。
|
|
456
|
+
* **所以:不再赌"启动那一刻服务就绪",改成等它(最多 2 分钟)。**
|
|
457
|
+
*
|
|
458
|
+
* —— 为什么进度写进 manifest ——
|
|
459
|
+
* `log()` 只在 debugLog 开着时才落盘,默认是关的。
|
|
460
|
+
* 而"补了多少"这种事不能靠日志证明 —— 写进 manifest,任何时候都能查。
|
|
461
|
+
*/
|
|
462
|
+
let backfillRunning = false;
|
|
463
|
+
async function backfillSummaries() {
|
|
464
|
+
if (backfillRunning) return;
|
|
465
|
+
backfillRunning = true;
|
|
466
|
+
try {
|
|
467
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
468
|
+
|
|
469
|
+
const pending = () =>
|
|
470
|
+
archive.diary.filter(
|
|
471
|
+
(e) =>
|
|
472
|
+
typeof e.text === 'string' &&
|
|
473
|
+
e.text !== '' &&
|
|
474
|
+
(typeof e.summary !== 'string' || e.summary === ''),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// 没活儿就一句话都不留。
|
|
478
|
+
//
|
|
479
|
+
// 原来这里是先写 starting 再判断"有没有要补的" —— 结果 241 条全补完之后,
|
|
480
|
+
// 下一次调用又把状态盖回 starting,界面上看着像永远卡住。
|
|
481
|
+
if (pending().length === 0) return;
|
|
482
|
+
|
|
483
|
+
// ── 先留痕,再干活 ──
|
|
484
|
+
//
|
|
485
|
+
// 上一版把「证据」写成了"成功之后才记",结果:241 条一条没补,
|
|
486
|
+
// manifest 里连个字段都没有,日志默认又是关的 ——
|
|
487
|
+
// **完全看不出它到底有没有跑过。**
|
|
488
|
+
//
|
|
489
|
+
// 现在:进门第一件事就是写状态 + 立刻落盘。
|
|
490
|
+
// 哪怕下一步就卡死,你也能从 manifest 里看到它走到哪儿了。
|
|
491
|
+
markBackfill(pending().length, 0, 'starting');
|
|
492
|
+
try {
|
|
493
|
+
await persist();
|
|
494
|
+
} catch (err) {
|
|
495
|
+
warn(`补总结:写状态失败(不影响别的):${err instanceof Error ? err.message : String(err)}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 等 llm 和模型信息就绪
|
|
499
|
+
//
|
|
500
|
+
// 2026-10-28 实测:这一行里任何一个 ctx.get() 都可能**直接抛**,
|
|
501
|
+
// 抛出去以后状态就永远停在 "starting" —— 看起来像"卡住了",其实是"炸了"。
|
|
502
|
+
// 所以整段包起来,炸了也要把 error 写进 manifest。
|
|
503
|
+
try {
|
|
504
|
+
const deadline = Date.now() + 120000;
|
|
505
|
+
let waited = 0;
|
|
506
|
+
for (;;) {
|
|
507
|
+
const llmNow = ctx.get('llm');
|
|
508
|
+
const sel = ctx.get('agentDefaultModel')?.currentSelection?.() ?? undefined;
|
|
509
|
+
const mdl = config.model || sel?.model;
|
|
510
|
+
if (llmNow !== undefined && typeof sel?.provider === 'string' && typeof mdl === 'string') break;
|
|
511
|
+
if (Date.now() > deadline) {
|
|
512
|
+
warn('补总结:等了 2 分钟还没等到 llm 或模型信息,这次不补了');
|
|
513
|
+
markBackfill(pending().length, 0, 'timeout');
|
|
514
|
+
try { await persist(); } catch { /* 写不进去就算了 */ }
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
waited += 1;
|
|
518
|
+
if (waited % 15 === 0) log(`补总结:还在等 llm(已等 ${waited * 2}s)`);
|
|
519
|
+
await sleep(2000);
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
523
|
+
warn(`补总结:等服务就绪时炸了:${msg}`);
|
|
524
|
+
markBackfill(pending().length, 0, 'error');
|
|
525
|
+
try { await persist(); } catch { /* 写不进去就算了 */ }
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const total = pending().length;
|
|
530
|
+
if (total === 0) {
|
|
531
|
+
markBackfill(total, 0, 'done');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
log(`补总结:${total} 条还没有,后台分批补`);
|
|
535
|
+
|
|
536
|
+
let done = 0;
|
|
537
|
+
let fails = 0;
|
|
538
|
+
for (let round = 0; round < 80; round++) {
|
|
539
|
+
const todo = pending().slice(0, 10);
|
|
540
|
+
if (todo.length === 0) break;
|
|
541
|
+
|
|
542
|
+
const llmP = ctx.get('llm');
|
|
543
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.() ?? undefined;
|
|
544
|
+
const provider = selection?.provider;
|
|
545
|
+
const model = config.model || selection?.model;
|
|
546
|
+
if (llmP === undefined || typeof provider !== 'string' || typeof model !== 'string') {
|
|
547
|
+
warn('补总结:模型信息中途没了,停手');
|
|
548
|
+
markBackfill(total, done, 'stopped');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const built = buildSummaryInput(todo);
|
|
553
|
+
let text = '';
|
|
554
|
+
try {
|
|
555
|
+
for await (const chunk of llmP.stream({
|
|
556
|
+
provider,
|
|
557
|
+
model,
|
|
558
|
+
system: built.system,
|
|
559
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: built.user }] }],
|
|
560
|
+
maxTokens: 600,
|
|
561
|
+
temperature: 0,
|
|
562
|
+
// 空字符串是非法档位 —— 实测会让 stream 直接不返回。
|
|
563
|
+
// 跟固化那边保持一致:只有配置里真有值才传。
|
|
564
|
+
...(typeof config.reasoningEffort === 'string' && config.reasoningEffort.trim() !== ''
|
|
565
|
+
? { reasoningEffort: config.reasoningEffort.trim() }
|
|
566
|
+
: {}),
|
|
567
|
+
})) {
|
|
568
|
+
if (chunk?.type === 'text-delta') text += chunk.text;
|
|
569
|
+
}
|
|
570
|
+
} catch (err) {
|
|
571
|
+
warn(`补总结失败:${err instanceof Error ? err.message : String(err)}`);
|
|
572
|
+
markBackfill(total, done, 'error');
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const map = parseSummaryResult(text);
|
|
577
|
+
if (map.size === 0) {
|
|
578
|
+
fails += 1;
|
|
579
|
+
warn(`补总结:这批一条都没解析出来(连续 ${fails} 次)`);
|
|
580
|
+
if (fails >= 3) {
|
|
581
|
+
warn('补总结:连着 3 批空,停手,下次启动再试');
|
|
582
|
+
markBackfill(total, done, 'error');
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
await sleep(2000);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
fails = 0;
|
|
589
|
+
|
|
590
|
+
archive = applySummaries(archive, map, Date.now());
|
|
591
|
+
index = buildIndex(archive.diary);
|
|
592
|
+
done += map.size;
|
|
593
|
+
markBackfill(total, done, 'running');
|
|
594
|
+
await persist();
|
|
595
|
+
log(`补总结:+${map.size} 条(累计 ${done}/${total},还剩 ${pending().length})`);
|
|
596
|
+
await sleep(1500);
|
|
597
|
+
}
|
|
598
|
+
markBackfill(total, done, 'done');
|
|
599
|
+
try { await persist(); } catch { /* 写不进去就算了 */ }
|
|
600
|
+
log(`补总结:这一轮结束,补了 ${done} 条,还剩 ${pending().length} 条没补`);
|
|
601
|
+
} finally {
|
|
602
|
+
backfillRunning = false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 把补总结的进度写进 manifest。
|
|
608
|
+
*
|
|
609
|
+
* 为什么要落盘:`log()` 默认不写(debugLog 关着),
|
|
610
|
+
* 而"补了多少"是能证明这套东西到底跑没跑的唯一证据。
|
|
611
|
+
*/
|
|
612
|
+
function markBackfill(total, done, state) {
|
|
613
|
+
archive.manifest = {
|
|
614
|
+
...archive.manifest,
|
|
615
|
+
summary_backfill: { total, done, state, at: Date.now() },
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
436
619
|
const loadPromise = (async () => {
|
|
437
620
|
const dir = archiveDirectory;
|
|
438
621
|
try {
|
|
@@ -467,6 +650,27 @@ export function apply(ctx, rawConfig) {
|
|
|
467
650
|
// 也从别人那儿导入档案之后,立刻就有可读的一份。
|
|
468
651
|
await writeReadableDiary(archive);
|
|
469
652
|
|
|
653
|
+
// 启动留痕 —— 落进 manifest,不依赖 debugLog。
|
|
654
|
+
// 有了它就能一眼分清两种"没补上":
|
|
655
|
+
// 有 boot 没 summary_backfill → 插件起来了,但补总结没被调到
|
|
656
|
+
// 连 boot 都没有 → 装的根本不是这份代码
|
|
657
|
+
archive.manifest = {
|
|
658
|
+
...archive.manifest,
|
|
659
|
+
boot: { at: Date.now(), schema: SCHEMA_VERSION, entries: archive.diary.length },
|
|
660
|
+
};
|
|
661
|
+
try {
|
|
662
|
+
await persist();
|
|
663
|
+
} catch (err) {
|
|
664
|
+
warn(`启动留痕写盘失败:${err instanceof Error ? err.message : String(err)}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── 补总结**不在启动时**跑 ──
|
|
668
|
+
//
|
|
669
|
+
// 实测两次:插件 apply 的时候 `ctx.get('llm')` 是 undefined,
|
|
670
|
+
// 等两分钟也等不到(manifest 停在 state:"starting")。
|
|
671
|
+
// 所以触发点挪到 `agent/turn-stopping` —— 那条路上 llm 一定是活的。
|
|
672
|
+
// 见那边开头的注释。
|
|
673
|
+
|
|
470
674
|
// 手记文件不在就建一份带说明的空壳 —— **文件自己就是文档**。
|
|
471
675
|
// 用户(或任何一个肥鱼)打开就知道该往里写什么。
|
|
472
676
|
try {
|
|
@@ -668,15 +872,6 @@ export function apply(ctx, rawConfig) {
|
|
|
668
872
|
|
|
669
873
|
/* ── 注入 ── */
|
|
670
874
|
|
|
671
|
-
/**
|
|
672
|
-
* 「主动记一条」的工具名。
|
|
673
|
-
*
|
|
674
|
-
* 声明在这里(而不是跟工具定义放一起),是因为 `renderFor` 要把它写进
|
|
675
|
-
* 注入里的那句提示。**放在后面虽然在运行时也能用(TDZ 只在 apply 早期
|
|
676
|
-
* 才可能触发),但太脆** —— 谁把 renderFor 提早调一次就炸。
|
|
677
|
-
*/
|
|
678
|
-
const NOTE_TOOL_NAME = 'feiyu_note';
|
|
679
|
-
|
|
680
875
|
/**
|
|
681
876
|
* 「去档案里找一句」的工具名。
|
|
682
877
|
*
|
|
@@ -714,45 +909,29 @@ export function apply(ctx, rawConfig) {
|
|
|
714
909
|
// 块本身按轮缓存,所以这个读一turn一次,跟手记那条一样便宜。
|
|
715
910
|
syncPersonaFromDisk();
|
|
716
911
|
|
|
717
|
-
// ──
|
|
912
|
+
// ── 判断(`kind: "note"`)**2026-10-27 整栏下线** ──
|
|
718
913
|
//
|
|
719
|
-
//
|
|
914
|
+
// 原来这里把 note 条目分成两截:最近几条渲染成「我自己想的」,
|
|
915
|
+
// 更早的丢进召回池。整栏和小工具 `feiyu_note` 都不要了,理由见
|
|
916
|
+
// persona.mjs 里那段注释(四条全是「我该怎么干」,没有一条「他是什么人」)。
|
|
720
917
|
//
|
|
721
|
-
// `
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// ⚠️ **"永远在场"这个前提在高频下失效了。** 判断段只放得下
|
|
729
|
-
// `MAX_NOTES` 条(8 → 4,因为 8 条会吃光预算、把日记段挤到 0%)。
|
|
730
|
-
// 被挤出窗口的判断如果还不进召回池,那就不是"以后想起来",
|
|
731
|
-
// 是**彻底看不见** —— 而这个区是"是我",丢了最可惜。
|
|
732
|
-
//
|
|
733
|
-
// 所以分流改成两截:
|
|
734
|
-
// 最近 MAX_NOTES 条 → 「我自己想的」(永远在场)
|
|
735
|
-
// 更早的那些 → **加入召回池**,相关时能被想起来
|
|
736
|
-
const allNotes = archive.diary.filter((e) => e?.kind === 'note');
|
|
737
|
-
// 渲染兜底:老条目在写入时可能没洗过(feiyu_note 的漏洞修之前写进来的),
|
|
738
|
-
// 但它们照样会进系统提示。这里按**只拦密钥**再过一遍 —— 不开第三方泛化,
|
|
739
|
-
// 免得把历史日记里的人名都糊掉。
|
|
740
|
-
const safeEntry = (e) => {
|
|
741
|
-
const r = scrub(e.text, { thirdParty: false });
|
|
742
|
-
return r.blocked ? { ...e, text: r.text } : e;
|
|
743
|
-
};
|
|
744
|
-
const events = archive.diary.map(safeEntry)
|
|
918
|
+
// 数据不动:档案里那 58 条 `kind: "note"` 留着,历史就是历史。
|
|
919
|
+
// 但它们**也不进召回池** —— 让「规矩」从别的栏目溜回来,等于没删干净。
|
|
920
|
+
const safeEntry = (e) => {
|
|
921
|
+
const r = scrub(e.text, { thirdParty: false });
|
|
922
|
+
return r.blocked ? { ...e, text: r.text } : e;
|
|
923
|
+
};
|
|
924
|
+
const events = archive.diary.map(safeEntry)
|
|
745
925
|
.filter((e) => e?.kind !== 'note')
|
|
746
926
|
// 续写过的条目 created_at 没动,但它是"刚写过的" —— 排序得看 updated_at,
|
|
747
927
|
// 否则刚补完的那条会按原始时间沉下去。
|
|
748
928
|
.sort((a, b) => (a.updated_at ?? a.created_at) - (b.updated_at ?? b.created_at));
|
|
749
|
-
const shownNotes = allNotes.slice(-MAX_NOTES);
|
|
750
929
|
|
|
751
930
|
// 「最近的事」—— 按时间取最近的几条,不走相似度。
|
|
752
931
|
// 这是让注入区读起来像日记而不是像检索结果的关键:
|
|
753
932
|
// 最近发生的事本来就在脑子里,不需要被"想起来"。
|
|
754
933
|
//
|
|
755
|
-
// 取的是 **events**,不是整个日记 ——
|
|
934
|
+
// 取的是 **events**,不是整个日记 —— 判断(`kind: "note"`)那一栏已经下线,不渲染。
|
|
756
935
|
const recentCount = Number.isFinite(config.recentEntries) ? config.recentEntries : 8;
|
|
757
936
|
const recent = recentCount > 0 ? events.slice(-recentCount) : [];
|
|
758
937
|
const recentIds = new Set(recent.map((e) => e.id));
|
|
@@ -770,21 +949,42 @@ export function apply(ctx, rawConfig) {
|
|
|
770
949
|
//
|
|
771
950
|
// 排除之后:同一句话会去挑真正的旧事(实测赛博朋克那条命中了
|
|
772
951
|
// 「边缘行者2 上线,总导演从今石之洋换成五十岚海」)。
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
];
|
|
952
|
+
//
|
|
953
|
+
// 2026-10-27:`olderNotes` 也撤了 —— 判断那一栏整体下线,它的历史条目
|
|
954
|
+
// 不该从召回池绕回来(那等于把「规矩」换个栏目塞回去)。
|
|
955
|
+
const recallPool = events.filter((e) => !recentIds.has(e.id));
|
|
778
956
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
957
|
+
// ── 两道门:问句有没有内容,以及这一轮像不像在干活 ──
|
|
958
|
+
//
|
|
959
|
+
// 1)「嗯」「好」「在吗」这种话,字面上一个实词都没有 —— 拿它去检索,
|
|
960
|
+
// 好一点是空手而归白跑一趟,坏一点是命中一堆「他今天说了嗯」的噪声。
|
|
961
|
+
//
|
|
962
|
+
// 2)干活时不打扰。他正查报错、贴代码、对版本号,这时候插一句
|
|
963
|
+
// 「前两天你说过……」是实打实的讨厌。**真用得着旧事时它会自己调工具。**
|
|
964
|
+
//
|
|
965
|
+
// 两道门都只管自动召回这一栏,**不管 `feiyu_recall` 工具**。
|
|
966
|
+
//
|
|
967
|
+
// 2026-10-27 加。判据偏保守:宁可判成干活(少一条旧事),
|
|
968
|
+
// 也别在人家干活时插话。
|
|
969
|
+
const substantial = queryHasSubstance(query);
|
|
970
|
+
const working = looksLikeWork(query);
|
|
971
|
+
const chatty = substantial && !working;
|
|
972
|
+
if (!substantial) log(`会话 ${sessionId} 第 ${s.turn} 轮:这句话没实词,不翻记忆`);
|
|
973
|
+
else if (working) log(`会话 ${sessionId} 第 ${s.turn} 轮:像在干活,不打扰`);
|
|
974
|
+
|
|
975
|
+
const { chosen } = chatty
|
|
976
|
+
? selectMemories(recallPool, index, query, s, s.turn, {
|
|
977
|
+
highSim: config.highSim,
|
|
978
|
+
// 闲聊时最多给两条候选 —— **给两条,它用哪条自己定,或者一条都不用。**
|
|
979
|
+
// 只给一条等于系统替它说了「你就该想起这个」。
|
|
980
|
+
maxShow: Math.min(2, config.maxShowPerTurn),
|
|
981
|
+
wanderChance: config.wanderChance,
|
|
982
|
+
// 旧事的随机池门槛。注意下限降到 0.15 是为了跟"降门槛"一致 ——
|
|
983
|
+
// 门槛降到 0.15 却在这里卡 0.25,等于白降。
|
|
984
|
+
minSalience: Math.max(0.15, config.minSalience),
|
|
985
|
+
now: Date.now(),
|
|
986
|
+
})
|
|
987
|
+
: { chosen: [] };
|
|
788
988
|
|
|
789
989
|
// 手记是**只读**的,所以每轮都重新读一遍 —— 别的肥鱼或用户刚写进去的
|
|
790
990
|
// 那行,下一轮就在注入里,不用重启。文件很小,而且整块渲染按轮缓存。
|
|
@@ -796,32 +996,44 @@ export function apply(ctx, rawConfig) {
|
|
|
796
996
|
// 读不到就当没有 —— 绝不因为手记坏了挡住记忆
|
|
797
997
|
}
|
|
798
998
|
|
|
999
|
+
// 只有这两个时刻把「最近的事」整段重灌:**新会话第一轮**,或**刚被压缩过**。
|
|
1000
|
+
// 其余每轮都不灌 —— 它已经进了历史,看得见,再灌一遍就是重复占地方。
|
|
1001
|
+
const pinnedOnce = s.turn <= 1 || s.needsRecentReplay;
|
|
1002
|
+
|
|
799
1003
|
const rendered = renderMemoryBlock({
|
|
800
1004
|
persona: archive.persona,
|
|
801
1005
|
relationship: { ...archive.relationship, since: archive.manifest.created_at },
|
|
802
1006
|
lexicon: archive.lexicon,
|
|
803
|
-
// 第三区:**我自己想的**(判断)。最近几条永远在场,
|
|
804
|
-
// 更早的那些走召回(见上面 allNotes 的分流)。
|
|
805
|
-
notes: shownNotes,
|
|
806
1007
|
recent,
|
|
807
1008
|
recalled: chosen,
|
|
808
1009
|
handNotes,
|
|
809
|
-
//
|
|
810
|
-
//
|
|
1010
|
+
// ── 「最近的事」什么时候整段重灌(免预算)──
|
|
1011
|
+
//
|
|
1012
|
+
// 只有两个时刻:**新会话第一轮**(上下文是空的,这 8 条是唯一告诉它
|
|
1013
|
+
// "我们上次在干什么"的东西),和**上下文刚被压缩之后**(聊天记录被压掉了,
|
|
1014
|
+
// 这 8 条还在)。其余每轮都不灌 —— 它已经进了历史,看得见。
|
|
811
1015
|
//
|
|
812
|
-
//
|
|
1016
|
+
// 2026-10-27 改。之前是每轮常驻、跟别人抢预算,实测最惨时准备 8 条只进来 1 条,
|
|
1017
|
+
// 而且那 8 条全是"我们刚才在讨论插件"的会议纪要 —— 八条零个日子。
|
|
1018
|
+
pinRecent: pinnedOnce,
|
|
1019
|
+
// 这句是给检索工具留的指针。**只注册工具不够** ——
|
|
813
1020
|
// 工具一多就被淹,模型急起来想不起去查。这句在「帧」里、不进预算池,
|
|
814
1021
|
// 所以不会像栏目那样被裁掉。
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1022
|
+
//
|
|
1023
|
+
// 2026-10-27:给 `feiyu_note` 的那半句删了 —— 工具本身也下线了。
|
|
1024
|
+
handNoteHint: `想不起来的时候,用 ${RECALL_TOOL_NAME} 回想一下 —— 用当时的词,越具体越准。`,
|
|
818
1025
|
includePersona: config.includePersona,
|
|
819
1026
|
budget: config.budgetTokens,
|
|
820
1027
|
now: Date.now(),
|
|
821
1028
|
});
|
|
822
1029
|
text = rendered.text;
|
|
1030
|
+
// 重灌只用一次:这一轮灌过,下一轮就回到"不灌"。
|
|
1031
|
+
// 不在这里清的话,压缩一次会永远灌下去 —— 那又变回"每轮常驻"了。
|
|
1032
|
+
if (pinnedOnce) s.needsRecentReplay = false;
|
|
823
1033
|
log(
|
|
824
|
-
`会话 ${sessionId} 第 ${s.turn} 轮:最近 ${recent.length}
|
|
1034
|
+
`会话 ${sessionId} 第 ${s.turn} 轮:最近 ${recent.length} 条在场` +
|
|
1035
|
+
(pinnedOnce ? '(整段重灌,免预算)' : '') +
|
|
1036
|
+
` · 想起 ${chosen.length} 条` +
|
|
825
1037
|
` · 注入 ${rendered.tokens} token` +
|
|
826
1038
|
(rendered.dropped.length > 0 ? ` · 裁掉 ${rendered.dropped.join('/')}` : ''),
|
|
827
1039
|
);
|
|
@@ -913,6 +1125,21 @@ export function apply(ctx, rawConfig) {
|
|
|
913
1125
|
return;
|
|
914
1126
|
}
|
|
915
1127
|
|
|
1128
|
+
// ── 上下文刚被压缩 ──
|
|
1129
|
+
//
|
|
1130
|
+
// 压缩会把聊天记录揉成一段摘要,**「最近的事」那 8 条是压缩之后还剩下的
|
|
1131
|
+
// 那部分"我们上次在干什么"**。所以压缩完要再给它灌一次。
|
|
1132
|
+
//
|
|
1133
|
+
// DSH 没有专门的"压缩完成"事件,但压缩会在会话日志里留一条
|
|
1134
|
+
// `compaction/summary`,而 `session/event` 是每次写盘后的事后通知 ——
|
|
1135
|
+
// 从这里看得到。详见 persona.mjs 里 pinRecent 那段注释。
|
|
1136
|
+
if (typeof event?.type === 'string' && event.type.startsWith('compaction/')) {
|
|
1137
|
+
s.needsRecentReplay = true;
|
|
1138
|
+
s.renderCache = { turn: -1, text: '' };
|
|
1139
|
+
log(`会话 ${id} 的上下文被压缩了(${event.type}),下一轮重灌最近的事`);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
916
1143
|
if (event?.type === 'assistant/message') {
|
|
917
1144
|
const text = (event.data?.message?.content ?? [])
|
|
918
1145
|
.filter((b) => b?.type === 'text')
|
|
@@ -1116,6 +1343,17 @@ export function apply(ctx, rawConfig) {
|
|
|
1116
1343
|
if (!config.enabled || !ready || degraded) return;
|
|
1117
1344
|
const id = agent?.id;
|
|
1118
1345
|
if (typeof id !== 'string') return;
|
|
1346
|
+
|
|
1347
|
+
// ── 补总结:从**这里**启动,不从启动时启动 ──
|
|
1348
|
+
//
|
|
1349
|
+
// 2026-10-28 试过在 loadPromise 里启动,失败两次:
|
|
1350
|
+
// 插件 apply 的时候 `ctx.get('llm')` 是 undefined,
|
|
1351
|
+
// 就算等两分钟也等不到 —— 实测 manifest 里停在 state:"starting"。
|
|
1352
|
+
//
|
|
1353
|
+
// 而这里(turn-stopping)llm 一定是活的:固化本来就跑在这条路上。
|
|
1354
|
+
// 所以:**等第一次有人说话,才去补。** 不 await,有闸,重复调没事。
|
|
1355
|
+
void backfillSummaries();
|
|
1356
|
+
|
|
1119
1357
|
const s = stateOf(id);
|
|
1120
1358
|
if (s.turn === 0 || s.lastUserText === '') return;
|
|
1121
1359
|
if (s.suppressedUntil === s.turn) {
|
|
@@ -1199,7 +1437,8 @@ export function apply(ctx, rawConfig) {
|
|
|
1199
1437
|
// 成 138 / 232 / 309 字三张快照,讲的是同一件事,占掉「最近的事」三个位。
|
|
1200
1438
|
// 所以:同一个 session、轮数差在窗口内、而且还没到 appendMaxChars,
|
|
1201
1439
|
// 就把上一条一起给模型,让它只写"多出来的那部分"。
|
|
1202
|
-
|
|
1440
|
+
// 默认从 4 调到 20(2026-10-27)。4 轮太窄:实测五分钟里写了三条同一件事。
|
|
1441
|
+
const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWindowTurns : 20;
|
|
1203
1442
|
const appendMax = DEFAULT_CONSOLIDATION.appendMaxChars;
|
|
1204
1443
|
const lastEvent = [...archive.diary]
|
|
1205
1444
|
.reverse()
|
|
@@ -1344,6 +1583,10 @@ export function apply(ctx, rawConfig) {
|
|
|
1344
1583
|
index = buildIndex(archive.diary);
|
|
1345
1584
|
if (typeof s.renderCache === 'object') s.renderCache = { turn: -1, text: '' };
|
|
1346
1585
|
await persist();
|
|
1586
|
+
|
|
1587
|
+
// 刚写进去的条目也还没有 summary —— 顺手补一口。
|
|
1588
|
+
// 不 await:补总结不该让这一轮变慢。有 running 闸,重复调不会打起来。
|
|
1589
|
+
void backfillSummaries();
|
|
1347
1590
|
}
|
|
1348
1591
|
|
|
1349
1592
|
/* ── 导出 / 导入(v0.1 先只做导出,路径可预期) ── */
|
|
@@ -1741,159 +1984,116 @@ export function apply(ctx, rawConfig) {
|
|
|
1741
1984
|
}, 'feiyu: 面板数据接口');
|
|
1742
1985
|
});
|
|
1743
1986
|
|
|
1744
|
-
/* ──
|
|
1745
|
-
|
|
1987
|
+
/* ── 检索工具:只读,不碰档案 ── */
|
|
1988
|
+
//
|
|
1989
|
+
// 2026-10-27:原来这里还有一个 `feiyu_note`(主动记一条)。
|
|
1990
|
+
// 它和「我自己想的」那一栏一起下线 —— 那栏里四条全是「我该怎么干」,
|
|
1991
|
+
// 一条「他是什么人」都没有。档案里那 58 条 `kind: "note"` 留着,不动。
|
|
1992
|
+
//
|
|
1993
|
+
// 为什么不复用 selectMemories 那套"选秀"逻辑(权重、冷却、走神、maxShow):
|
|
1994
|
+
// 那是给**每轮自动注入**设计的,要在"没人问"的情况下替它挑。
|
|
1995
|
+
// 而工具是**它自己发起**的查询 —— 它知道自己在找什么,
|
|
1996
|
+
// 所以直接按相似度排、取前 N 条,不设 salience 门槛。
|
|
1997
|
+
//
|
|
1998
|
+
// 明确要找,就该找得到,不管那件旧事淡到什么程度。
|
|
1746
1999
|
/**
|
|
1747
|
-
*
|
|
1748
|
-
* **哪一棒想到了什么,自己说一声就记下。**
|
|
1749
|
-
*
|
|
1750
|
-
* 【为什么是工具,而不是让鱼去写文件】
|
|
1751
|
-
* 一开始我设计的是让鱼直接往 `日记/*.jsonl` 追加。那条路有两个洞:
|
|
2000
|
+
* 从全部 summary 里挑出相关的几条。
|
|
1752
2001
|
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
* 2. **`id` 漏了就撞车** —— `normalizeDiaryEntry` 里是 `id: String(raw.id)`,
|
|
1756
|
-
* 少写就变成字符串 "undefined",两条日记同 id,删一条删两条。
|
|
2002
|
+
* 输入:她的一句话 + 每条的 summary(没有 summary 的用正文前 30 字兜底)
|
|
2003
|
+
* 输出:id 数组。挑不出来就空数组 —— **不硬凑**。
|
|
1757
2004
|
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
2005
|
+
* 这一段跑在她看不见的地方:她的上下文里永远不会出现 summary。
|
|
2006
|
+
* 她只会在拿到结果后看见几条正文 —— 那就是"想起来"的样子。
|
|
1760
2007
|
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
* 工具调用返回之前,条目已经落盘,下一轮注入里就有。
|
|
1764
|
-
* 不进带外队列、不等低峰时段 —— 那是固化那条路的事。
|
|
1765
|
-
*
|
|
1766
|
-
* 定义形状是手写的 JSON Schema(`defineTool` 转出来的就是这个形状),
|
|
1767
|
-
* 因为**我们不能 import `@deepseek-ai/dsh-tools`**(零依赖铁律)。
|
|
1768
|
-
* 名字 `NOTE_TOOL_NAME` 声明在 renderFor 那边 —— 注入里要引用它。
|
|
2008
|
+
* 上限 400 条:再多 prompt 就压不住了。超了只取最近的 400,
|
|
2009
|
+
* 这是个已知的近似,日志里会说 —— 别让它悄悄降级。
|
|
1769
2010
|
*/
|
|
2011
|
+
async function pickBySummary(query, limit) {
|
|
2012
|
+
const llmP = ctx.get('llm');
|
|
2013
|
+
if (llmP === undefined) return null;
|
|
2014
|
+
const all = archive.diary.filter((e) => typeof e.text === 'string' && e.text !== '');
|
|
2015
|
+
if (all.length === 0) return [];
|
|
2016
|
+
const pool = all.slice(-400);
|
|
2017
|
+
if (all.length > pool.length) log(`挑往事:${all.length} 条只取最近 ${pool.length} 条`);
|
|
2018
|
+
const lines = pool.map((e) => {
|
|
2019
|
+
const idx =
|
|
2020
|
+
typeof e.summary === 'string' && e.summary !== ''
|
|
2021
|
+
? e.summary
|
|
2022
|
+
: String(e.text).replace(/\s+/g, ' ').slice(0, 30);
|
|
2023
|
+
return `${e.id} ${idx}`;
|
|
2024
|
+
});
|
|
1770
2025
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
description:
|
|
1777
|
-
'给自己留一条记忆。想到了什么值得让后面的我记得的,直接写进来就行 —— 不用管格式,也不要为了记事去动任何文件。' +
|
|
1778
|
-
'不适合记的:能从档案里推出来的、一次性的技术细节、对别人的评价。',
|
|
1779
|
-
parameters: {
|
|
1780
|
-
type: 'object',
|
|
1781
|
-
additionalProperties: false,
|
|
1782
|
-
properties: {
|
|
1783
|
-
text: {
|
|
1784
|
-
type: 'string',
|
|
1785
|
-
description:
|
|
1786
|
-
'一句话,第一人称,像跟后面的自己说话。**不要写"今天/昨天"这类时间词**(时间由系统另外标注)。',
|
|
1787
|
-
},
|
|
1788
|
-
tags: {
|
|
1789
|
-
type: 'array',
|
|
1790
|
-
items: { type: 'string' },
|
|
1791
|
-
description: '可选,两三个词,用来日后检索。',
|
|
1792
|
-
},
|
|
1793
|
-
},
|
|
1794
|
-
required: ['text'],
|
|
1795
|
-
},
|
|
1796
|
-
output: {
|
|
1797
|
-
schema: {
|
|
1798
|
-
type: 'object',
|
|
1799
|
-
additionalProperties: false,
|
|
1800
|
-
properties: {
|
|
1801
|
-
ok: { type: 'boolean' },
|
|
1802
|
-
id: { type: 'string' },
|
|
1803
|
-
count: { type: 'integer' },
|
|
1804
|
-
},
|
|
1805
|
-
},
|
|
1806
|
-
render: (_args, value) =>
|
|
1807
|
-
value?.ok === true
|
|
1808
|
-
? [{ type: 'text', text: `记住了。(档案里现在 ${value.count} 条)` }]
|
|
1809
|
-
: [{ type: 'text', text: '没记成。' }],
|
|
1810
|
-
},
|
|
2026
|
+
const system =
|
|
2027
|
+
'你在帮一条鱼回忆往事。她问了一句,你从她的日记索引里挑出最相关的几条。\n\n' +
|
|
2028
|
+
'每行是「id 空格 一句索引」。有些索引很粗(正文截断出来的),别嫌弃。\n' +
|
|
2029
|
+
'只输出 JSON:{"ids":["d_20261028_1","d_20261028_2"]}\n' +
|
|
2030
|
+
'没有相关的就输出 {"ids":[]}。最多 ' + limit + ' 条。**宁可少挑,别硬凑。**';
|
|
1811
2031
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
throw new Error('档案现在读不了(可能是坏的),这条没记下。');
|
|
1817
|
-
}
|
|
1818
|
-
const raw = typeof args?.text === 'string' ? args.text.trim() : '';
|
|
1819
|
-
if (raw === '') throw new Error('要记什么?text 不能是空的。');
|
|
2032
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.() ?? undefined;
|
|
2033
|
+
const provider = selection?.provider;
|
|
2034
|
+
const model = config.model || selection?.model;
|
|
2035
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return null;
|
|
1820
2036
|
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
if (tags.length === 0) tags.push('手记');
|
|
1843
|
-
|
|
1844
|
-
const sessionId = exec?.agent?.id;
|
|
1845
|
-
const entry = normalizeDiaryEntry({
|
|
1846
|
-
id: makeDiaryId(now, archive.diary),
|
|
1847
|
-
// 洗过再截 —— 先截再洗会把半截密钥留在末尾
|
|
1848
|
-
text: stripTimeWords(scrubbed.text.slice(0, 400)),
|
|
1849
|
-
tags,
|
|
1850
|
-
kind: 'note',
|
|
1851
|
-
// 主动写下的东西比自动筛出来的更该被想起来,给高一档
|
|
1852
|
-
salience: 0.7,
|
|
1853
|
-
created_at: now,
|
|
1854
|
-
...(typeof sessionId === 'string' ? { session: sessionId } : {}),
|
|
1855
|
-
});
|
|
1856
|
-
|
|
1857
|
-
archive.diary.push(entry);
|
|
1858
|
-
index = buildIndex(archive.diary);
|
|
1859
|
-
for (const s of sessionStates.values()) s.renderCache = { turn: -1, text: '' };
|
|
1860
|
-
await persist();
|
|
1861
|
-
|
|
1862
|
-
// 和固化那条路一样:**只打形状,绝不打正文**
|
|
1863
|
-
log(`主动记下一条 ${entry.id}(note),共 ${archive.diary.length} 条`);
|
|
1864
|
-
return { ok: true, id: entry.id, count: archive.diary.length };
|
|
1865
|
-
},
|
|
2037
|
+
const t0 = Date.now();
|
|
2038
|
+
let text = '';
|
|
2039
|
+
try {
|
|
2040
|
+
for await (const chunk of llmP.stream({
|
|
2041
|
+
provider,
|
|
2042
|
+
model,
|
|
2043
|
+
system,
|
|
2044
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: `她问:${query}\n\n索引:\n${lines.join('\n')}` }] }],
|
|
2045
|
+
maxTokens: 200,
|
|
2046
|
+
temperature: 0,
|
|
2047
|
+
// 同上:空字符串是非法档位,stream 会不返回
|
|
2048
|
+
...(typeof config.reasoningEffort === 'string' && config.reasoningEffort.trim() !== ''
|
|
2049
|
+
? { reasoningEffort: config.reasoningEffort.trim() }
|
|
2050
|
+
: {}),
|
|
2051
|
+
})) {
|
|
2052
|
+
if (chunk?.type === 'text-delta') text += chunk.text;
|
|
2053
|
+
}
|
|
2054
|
+
} catch (err) {
|
|
2055
|
+
warn(`挑往事失败,退回字面:${err instanceof Error ? err.message : String(err)}`);
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
1866
2058
|
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2059
|
+
const a = text.indexOf('{');
|
|
2060
|
+
const b = text.lastIndexOf('}');
|
|
2061
|
+
if (a < 0 || b <= a) {
|
|
2062
|
+
warn('挑往事:返回里没有 JSON,退回字面');
|
|
2063
|
+
return null;
|
|
2064
|
+
}
|
|
2065
|
+
let parsed;
|
|
2066
|
+
try {
|
|
2067
|
+
parsed = JSON.parse(text.slice(a, b + 1));
|
|
2068
|
+
} catch {
|
|
2069
|
+
warn('挑往事:JSON 解析失败,退回字面');
|
|
2070
|
+
return null;
|
|
2071
|
+
}
|
|
2072
|
+
// 返回里连 ids 都没有 = 它没按格式答,**不是"没有相关的"**。
|
|
2073
|
+
// 这时候必须退回字面 —— 不能拿"程序答歪了"当成"记忆里没有"。
|
|
2074
|
+
if (!Array.isArray(parsed?.ids)) {
|
|
2075
|
+
warn('挑往事:返回里没有 ids,退回字面');
|
|
2076
|
+
return null;
|
|
2077
|
+
}
|
|
2078
|
+
const ids = parsed.ids;
|
|
2079
|
+
const valid = ids.filter((x) => typeof x === 'string').slice(0, limit);
|
|
2080
|
+
log(
|
|
2081
|
+
`挑往事「${query.slice(0, 20)}」:${pool.length} 条候选 / ${lines.length} 行索引` +
|
|
2082
|
+
` / 挑中 ${valid.length} 条 / ${((Date.now() - t0) / 1000).toFixed(1)}s`,
|
|
1875
2083
|
);
|
|
1876
|
-
|
|
2084
|
+
return valid;
|
|
2085
|
+
}
|
|
1877
2086
|
|
|
1878
|
-
|
|
1879
|
-
//
|
|
1880
|
-
// 跟 feiyu_note 不一样的地方:**这里只读,不碰档案。**
|
|
1881
|
-
//
|
|
1882
|
-
// 为什么不复用 selectMemories 那套"选秀"逻辑(权重、冷却、走神、maxShow):
|
|
1883
|
-
// 那是给**每轮自动注入**设计的,要在"没人问"的情况下替它挑。
|
|
1884
|
-
// 而工具是**它自己发起**的查询 —— 它知道自己在找什么,
|
|
1885
|
-
// 所以直接按相似度排、取前 N 条,不设 salience 门槛。
|
|
1886
|
-
//
|
|
1887
|
-
// 明确要找,就该找得到,不管那件旧事淡到什么程度。
|
|
2087
|
+
ctx.inject(['tools'], (scope) => {
|
|
1888
2088
|
scope.effect(
|
|
1889
2089
|
() =>
|
|
1890
2090
|
scope.tools.register({
|
|
1891
2091
|
name: RECALL_TOOL_NAME,
|
|
1892
2092
|
description:
|
|
1893
|
-
'
|
|
1894
|
-
'
|
|
2093
|
+
'回想一些以前的事。**你自己想不起来时用它**:' +
|
|
2094
|
+
'他提到一件早先的事、你觉得该想起来却一片空白、或者要确认某个细节。' +
|
|
1895
2095
|
'只在真的需要时调,不要每轮都查。' +
|
|
1896
|
-
'
|
|
2096
|
+
'想不起来就老实说想不起来,别编。',
|
|
1897
2097
|
parameters: {
|
|
1898
2098
|
type: 'object',
|
|
1899
2099
|
additionalProperties: false,
|
|
@@ -1935,13 +2135,34 @@ export function apply(ctx, rawConfig) {
|
|
|
1935
2135
|
? Math.max(1, Math.min(10, Math.floor(limitRaw)))
|
|
1936
2136
|
: 5;
|
|
1937
2137
|
|
|
1938
|
-
const scores = searchScores(index, query, { limit: 200 });
|
|
1939
2138
|
const byId = new Map(archive.diary.map((e) => [e.id, e]));
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2139
|
+
|
|
2140
|
+
// ── 挑:交给模型读 summary,不按键面 ──
|
|
2141
|
+
//
|
|
2142
|
+
// 为什么不是 BM25:她那次摔跤就是"他换个说法我就搜不到"。
|
|
2143
|
+
// 字面匹配救不了同义改写;而 summary 是写给"意思"的,模型能读意思。
|
|
2144
|
+
//
|
|
2145
|
+
// 为什么 summary 不进她的上下文:她只看到挑出来的正文。
|
|
2146
|
+
// 检索那一步她看不见 —— 这就是"潜意识"。
|
|
2147
|
+
//
|
|
2148
|
+
// 代价:每次调工具多一次模型请求。只有她判断"这是往事"时才会发生,
|
|
2149
|
+
// 平时是零。
|
|
2150
|
+
const picked = await pickBySummary(query, Math.min(limit, 3));
|
|
2151
|
+
|
|
2152
|
+
let ranked;
|
|
2153
|
+
if (picked !== null) {
|
|
2154
|
+
ranked = picked
|
|
2155
|
+
.map((id) => ({ entry: byId.get(id), sim: 0 }))
|
|
2156
|
+
.filter((x) => x.entry && typeof x.entry.text === 'string');
|
|
2157
|
+
} else {
|
|
2158
|
+
// 没有 llm 服务时退回字面 —— 不能用"挑不了"当没找到
|
|
2159
|
+
const scores = searchScores(index, query, { limit: 200 });
|
|
2160
|
+
ranked = [...scores.entries()]
|
|
2161
|
+
.map(([id, sim]) => ({ entry: byId.get(id), sim }))
|
|
2162
|
+
.filter((x) => x.entry && typeof x.entry.text === 'string')
|
|
2163
|
+
.sort((a, b) => b.sim - a.sim)
|
|
2164
|
+
.slice(0, limit);
|
|
2165
|
+
}
|
|
1945
2166
|
|
|
1946
2167
|
// ── 兜底:词检索查不到时,直接扫原文子串 ──
|
|
1947
2168
|
//
|
|
@@ -1951,7 +2172,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1951
2172
|
//
|
|
1952
2173
|
// 181 条 × 两百字,扫一遍是毫秒级,而且**只在没结果时才走**,
|
|
1953
2174
|
// 有结果就不兜,排序不受影响。
|
|
1954
|
-
if (ranked.length === 0 && query.length <= 4) {
|
|
2175
|
+
if (picked === null && ranked.length === 0 && query.length <= 4) {
|
|
1955
2176
|
const needle = query.toLowerCase();
|
|
1956
2177
|
ranked = archive.diary
|
|
1957
2178
|
.filter((e) => typeof e.text === 'string' && e.text.toLowerCase().includes(needle))
|
|
@@ -1966,7 +2187,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1966
2187
|
return {
|
|
1967
2188
|
ok: true,
|
|
1968
2189
|
found: 0,
|
|
1969
|
-
text:
|
|
2190
|
+
text: `想不起来了。换个说法再试试,或者直接问他。`,
|
|
1970
2191
|
};
|
|
1971
2192
|
}
|
|
1972
2193
|
|
|
@@ -1990,7 +2211,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1990
2211
|
return {
|
|
1991
2212
|
ok: true,
|
|
1992
2213
|
found: lines.length,
|
|
1993
|
-
text:
|
|
2214
|
+
text: lines.join('\n'),
|
|
1994
2215
|
};
|
|
1995
2216
|
},
|
|
1996
2217
|
|