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.
@@ -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, MAX_NOTES } from '../core/persona.mjs';
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
- // `feiyu_note` 写出来的东西("别把前一种的辛苦当成功德")是**判断**,
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
- const olderNotes = allNotes.slice(0, -MAX_NOTES);
774
- const recallPool = [
775
- ...events.filter((e) => !recentIds.has(e.id)),
776
- ...olderNotes,
777
- ];
952
+ //
953
+ // 2026-10-27:`olderNotes` 也撤了 —— 判断那一栏整体下线,它的历史条目
954
+ // 不该从召回池绕回来(那等于把「规矩」换个栏目塞回去)。
955
+ const recallPool = events.filter((e) => !recentIds.has(e.id));
778
956
 
779
- const { chosen } = selectMemories(recallPool, index, query, s, s.turn, {
780
- highSim: config.highSim,
781
- maxShow: config.maxShowPerTurn,
782
- wanderChance: config.wanderChance,
783
- // 旧事的随机池门槛。注意下限降到 0.15 是为了跟"降门槛"一致 ——
784
- // 门槛降到 0.15 却在这里卡 0.25,等于白降。
785
- minSalience: Math.max(0.15, config.minSalience),
786
- now: Date.now(),
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
- handNoteHint:
816
- `想给后面的我留话,用 ${NOTE_TOOL_NAME} 工具说一声就行 —— 不用管格式,也别为了记事去动文件。\n` +
817
- `想不起来某件事时,用 ${RECALL_TOOL_NAME} 去档案里找一下 —— 用当时的词,越具体越准。`,
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} 条在场 · 想起 ${chosen.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
- const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWindowTurns : 4;
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
- * 1. **静默丢数据** —— `archiveToFiles()` 是整份重建,内存副本会把
1754
- * 外部追加的行整份盖掉,没有报错。
1755
- * 2. **`id` 漏了就撞车** —— `normalizeDiaryEntry` 里是 `id: String(raw.id)`,
1756
- * 少写就变成字符串 "undefined",两条日记同 id,删一条删两条。
2002
+ * 输入:她的一句话 + 每条的 summary(没有 summary 的用正文前 30 字兜底)
2003
+ * 输出:id 数组。挑不出来就空数组 —— **不硬凑**。
1757
2004
  *
1758
- * 交给插件办,这两个洞都不存在 —— 鱼只管说话,格式、id、关键词、
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
- ctx.inject(['tools'], (scope) => {
1772
- scope.effect(
1773
- () =>
1774
- scope.tools.register({
1775
- name: NOTE_TOOL_NAME,
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
- async execute(args, exec) {
1813
- await loadPromise;
1814
- if (!ready) {
1815
- // 档案坏了就说实话,别假装记下了(SPEC §10 降级优先)
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
- // 审计报告(F1,P1):`scrub()` 原来只在固化那条路调用,
1824
- // 而 feiyu_note 是"模型自己说一句、直接落盘"的写入口 ——
1825
- // 用户刚贴的 key、手机号、人名会被原样写进 diary/*.jsonl,
1826
- // 然后**每一轮都随系统提示发给模型 provider**。
1827
- //
1828
- // 这直接违背 README 那句「密钥硬拦……绝不入档」。
1829
- // 而且它不是边角场景:模型说"记一下这个 key"是很自然的动作。
1830
- const scrubbed = scrub(raw, {
1831
- thirdParty: config.thirdPartyRedaction !== false,
1832
- amountThreshold: config.amountThreshold,
1833
- });
1834
- if (scrubbed.blocked) {
1835
- log(`主动记的那条命中了密钥规则,已替换(${scrubbed.hits.length} 处)`);
1836
- }
1837
- const now = Date.now();
1838
- const tags = (Array.isArray(args?.tags) ? args.tags : [])
1839
- .filter((t) => typeof t === 'string' && t.trim() !== '')
1840
- .map((t) => scrub(t.trim(), { thirdParty: false }).text.slice(0, 12))
1841
- .slice(0, 4);
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
- presentCall: (args) => ({
1868
- card: 'generic',
1869
- title: '记住一条',
1870
- kind: 'other',
1871
- rawInput: args,
1872
- }),
1873
- }),
1874
- 'feiyu: note tool',
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
- log(`已注册工具 ${NOTE_TOOL_NAME}`);
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
- let ranked = [...scores.entries()]
1941
- .map(([id, sim]) => ({ entry: byId.get(id), sim }))
1942
- .filter((x) => x.entry && typeof x.entry.text === 'string')
1943
- .sort((a, b) => b.sim - a.sim)
1944
- .slice(0, limit);
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: `没找到关于「${query}」的记录。可以换个说法再试,或者直接问他。`,
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: `找到 ${lines.length} 条:\n${lines.join('\n')}`,
2214
+ text: lines.join('\n'),
1994
2215
  };
1995
2216
  },
1996
2217