sage-mem 0.9.13 → 0.9.15

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/README.md CHANGED
@@ -159,9 +159,10 @@ SAGE_MEM_DIR=/path/to/your/memory
159
159
 
160
160
  透明的东西值得说清楚它怎么选料,因为它直接决定注入多少 token:
161
161
 
162
- - **信号是 `description`,不是全文**。每条记忆的 `description`(「一句话说清这条是什么」)、文件名、以及可选的 `aliases`(别称)一起参与打分,最多注入 **10 条**相关记忆。旧版拿问题里的双字去全文里蒙,长记忆天生占便宜——实测长记忆在全文匹配下连寒暄问句都能注入上千 token;改用 `description` 后噪音大幅下降
163
- - **换个说法也能命中**:打分是**词面重合**(双字组覆盖率),不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
164
- - **正文超 1500 字符就截断**并标 `…(截断)`,单条记忆不会吃掉整个上下文
162
+ - **两段式检索:先摘要,再正文**。第一段用 `description`(「一句话说清这条是什么」)+ 文件名 + `aliases`(别称)打分,最多取 **10 条**候选;第二段对这些候选做**正文段落检索**(BM25,中文按双字词元),把最相关的那一段注入。旧版拿问题里的双字去全文里蒙,长记忆天生占便宜——实测长记忆在全文匹配下连寒暄问句都能注入上千 token。新版比的是**段落**,每份文件最多贡献一段,长文不再靠段数多占候选位
163
+ - **摘要没写到的,正文能找回来**。摘要是精准的一句话,代价是正文细节天生检索不到;摘要一条都没命中时改由正文检索兜底——此前这种情况直接等于「没有记忆可用」。正文检索只在问题**至少 4 个字符**时才跑:`继续` 这种两个字的问题拿去做正文匹配,命中的全是「继续」这个词本身
164
+ - **换个说法也能命中**:打分是**词面重合**,不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
165
+ - **注入的是命中段落,不是文件开头**。命中段落标成 `### 项目_部署.md:40-52`,agent 据此能直接跳到那几行核对来源;段落前补一句该记忆的 `description`,补回「这段是从哪条记忆切出来的」。`baseline` 与没命中正文的文件仍注入开头。单条正文超 1500 字符截断并标 `…(截断)`
165
166
  - **同一个问题不会重复注入**(按会话记签名),问第二遍不会又追加一遍
166
167
  - **单场会话累计注入到 60 KB 就停**(`MAX_SESSION_BYTES`)。条数上限管的是「一次注多少」,这条管「一场会话总共注多少」—— 长会话里问题一直在换、签名一直在变,累积本来没有上限。到顶之后新的记忆不再注入(这时该让 agent 直接读文件),**压缩或清空之后账面自动归零**,又能注进来
167
168
  - **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
package/lib/client.js CHANGED
@@ -112,13 +112,55 @@ const StarmapRuntime = (function (require, _react) {
112
112
  // 配色:五个色若取高饱和高亮度(#ffd27d / #6fe3ff / #b18cff / #7dffb0),
113
113
  // 配上星芒与脉冲环,整体读起来像贴纸。现在统一降饱和、压亮度 ——
114
114
  // 深空画布上够辨认,又不抢眼。
115
+ //
116
+ // 两档取值:`color` = 深色档(夜空画布),`light` = 浅色档(纸本设色)。
117
+ // 浅色档不用「深色档降亮度」那种做法 —— 高饱和冷色压在暖纸上会发脏;
118
+ // 换成明度接近的矿物颜料色(赭石/石青/紫墨/石绿/墨),色相错开才分得清类型。
115
119
  const KIND_META = {
116
- user: { label: "用户", color: "#e0c07a" },
117
- feedback: { label: "反馈", color: "#7ec8da" },
118
- project: { label: "项目", color: "#ac9edc" },
119
- reference: { label: "参考", color: "#92ceaa" },
120
- special: { label: "特殊", color: "#bcc3d0" },
120
+ user: { label: "用户", color: "#e0c07a", light: "#a4652c" },
121
+ feedback: { label: "反馈", color: "#7ec8da", light: "#3a6f8f" },
122
+ project: { label: "项目", color: "#ac9edc", light: "#6a5b8f" },
123
+ reference: { label: "参考", color: "#92ceaa", light: "#4a7c63" },
124
+ special: { label: "特殊", color: "#bcc3d0", light: "#5b5750" },
121
125
  };
126
+ /**
127
+ * 宿主主题挂钩 —— 与 ui-theme 同一处:body[data-ds-dark-theme] 存在即深色档。
128
+ */
129
+ function isDarkTheme() {
130
+ return typeof document !== "undefined" && !!document.body
131
+ && document.body.hasAttribute("data-ds-dark-theme");
132
+ }
133
+
134
+ /** 类型色按当前主题取(meta.color / meta.light)。 */
135
+ function kindColor(meta) {
136
+ const m = meta || KIND_META.special;
137
+ return (isDarkTheme() ? m.color : m.light) || m.color;
138
+ }
139
+
140
+ /**
141
+ * 画布调色板:深色档 = 夜空,浅色档 = 暖纸 + 晕染。
142
+ * 两套渲染器(WebGL / canvas2d)都从这里取色,别各写一份。
143
+ * bgGl 是 WebGL shader 用的 0-1 分量(与 bg2d 是同一色,写法不同而已)。
144
+ */
145
+ function starmapPalette() {
146
+ if (isDarkTheme()) {
147
+ return {
148
+ bg2d: ["#0b1026", "#101731"],
149
+ bgGl: [[0.105, 0.117, 0.145], [0.043, 0.050, 0.065]],
150
+ dust: "#9fb3dd",
151
+ archive: [0.46, 0.50, 0.58],
152
+ vignette: null,
153
+ };
154
+ }
155
+ return {
156
+ bg2d: ["#faf6ed", "#e9e0cd"],
157
+ bgGl: [[0.980, 0.965, 0.929], [0.914, 0.878, 0.804]],
158
+ dust: "#c9bfa6",
159
+ archive: [0.62, 0.60, 0.55],
160
+ vignette: { color: "#7a6a48", a: 0.10 },
161
+ };
162
+ }
163
+
122
164
  const KIND_ORDER = ["user", "feedback", "project", "reference", "special"];
123
165
  const FRESH_MS = 7 * 86400000;
124
166
 
@@ -370,79 +412,71 @@ const StarmapRuntime = (function (require, _react) {
370
412
  // ── 样式(data-plugin-css 约定,幂等注入)──
371
413
  const CSS = [
372
414
  ".smap-footer-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1,transparent);background:transparent;color:var(--dsw-alias-label-secondary,inherit);border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap;}",
373
- ".smap-fab{position:fixed;right:0;top:15%;transform:translateY(-50%);z-index:900;width:36px;height:64px;border-radius:12px 0 0 12px;border:1px solid rgba(150,170,230,.35);border-right:none;background:rgba(13,18,38,.78);color:#ffd27d;font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:.5;pointer-events:auto;transition:opacity .15s,background .15s,width .15s;box-shadow:-4px 0 18px rgba(0,0,0,.35);font-family:inherit;}",
374
- ".smap-fab:hover{opacity:1;width:46px;background:rgba(20,28,56,.95);}",
415
+ ".smap-fab{position:fixed;right:0;top:15%;transform:translateY(-50%);z-index:900;width:36px;height:64px;border-radius:12px 0 0 12px;border:1px solid var(--dsw-alias-border-l1);border-right:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary);font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:.5;pointer-events:auto;transition:opacity .15s,background .15s,width .15s;box-shadow:-4px 0 18px rgba(0,0,0,.35);font-family:inherit;}",
416
+ ".smap-fab:hover{opacity:1;width:46px;background:var(--dsw-alias-bg-layer-1);}",
375
417
  ".smap-footer-btn:hover{color:var(--dsw-alias-label-primary,inherit);border-color:var(--dsw-alias-border-l2,#8886);}",
376
418
  ".smap-footer-icon{font-size:13px;line-height:1;}",
377
- ".smap-veil{position:fixed;inset:0;z-index:1000;background:rgba(4,6,16,.72);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;pointer-events:auto;animation:smap-fade-in .18s ease-out;}",
378
- ".smap-modal{width:min(980px,94vw);height:min(680px,92vh);background:linear-gradient(160deg,#0d1226,#111a33);border:1px solid rgba(140,160,220,.22);border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.55);padding:16px 18px;display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;animation:smap-pop-in .22s cubic-bezier(.2,.9,.3,1.15);}",
419
+ ".smap-veil{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.45));backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;pointer-events:auto;animation:smap-fade-in .18s ease-out;}",
420
+ ".smap-modal{width:min(980px,94vw);height:min(680px,92vh);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.55);padding:16px 18px;display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;animation:smap-pop-in .22s cubic-bezier(.2,.9,.3,1.15);}",
379
421
  "@keyframes smap-fade-in{from{opacity:0}to{opacity:1}}",
380
422
  "@keyframes smap-pop-in{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:none}}",
381
- // 星图整块含 header 与图例,同属一张深色画布。
382
- //
383
- // 不这么做的下场:宿主切到**浅色主题**后,这个组件里硬编码的
384
- // 浅色文字(#dbe4f5 系)落在白色卡片上,标题、搜索框、图例几乎看不见 ——
385
- // 一片「重影」。星图本来就不打算跟随宿主主题(它是一张夜空),
386
- // 那就把边界画清楚:整块深色,而不是只有中间那半块。
387
- ".smap-card{background:#0c0e12;border-radius:10px;padding:12px 14px;color:#e3e7ef;display:flex;flex-direction:column;flex:1;min-height:0;}",
423
+ ".smap-card{background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;flex:1;min-height:0;}",
388
424
  ".smap-head{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}",
389
425
  ".smap-title{font-size:15px;font-weight:600;letter-spacing:.06em;}",
390
- ".smap-sub{font-size:11px;opacity:.55;}",
426
+ ".smap-sub{font-size:11px;color:var(--dsw-alias-label-secondary);}",
391
427
  ".smap-spacer{flex:1;}",
392
- ".smap-search{background:rgba(255,255,255,.06);border:1px solid rgba(150,170,230,.25);border-radius:8px;color:#dbe4f5;font-size:12px;padding:4px 9px;outline:none;width:160px;font-family:inherit;}",
393
- ".smap-search:focus{border-color:rgba(150,170,230,.55);}",
394
- ".smap-search::placeholder{color:rgba(219,228,245,.35);}",
395
- ".smap-btn{background:rgba(255,255,255,.07);border:1px solid rgba(150,170,230,.25);border-radius:8px;color:#dbe4f5;font-size:12px;padding:4px 10px;cursor:pointer;font-family:inherit;}",
396
- ".smap-btn:hover{background:rgba(255,255,255,.13);}",
397
- // 开态(档案馆开关)与主按钮(恢复):沿用同一套夜色调,不新增色板。
398
- // 「显示档案馆」的开/关要一眼看出来:开 = 亮边 + 蓝调底 + 亮字,关 = 整体压暗。
399
- ".smap-btn.on{border-color:rgba(150,170,230,.78);background:rgba(122,158,255,.20);color:#eef3ff;}",
400
- '.smap-ar-toggle[aria-pressed="false"]{opacity:.60;}',
428
+ ".smap-search{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 9px;outline:none;width:160px;font-family:inherit;}",
429
+ ".smap-search:focus{border-color:var(--dsw-alias-border-l2);}",
430
+ ".smap-search::placeholder{color:var(--dsw-alias-label-tertiary);}",
431
+ ".smap-btn{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 10px;cursor:pointer;font-family:inherit;}",
432
+ ".smap-btn:hover{background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));}",
433
+ ".smap-btn.on{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-primary);}",
434
+ ".smap-ar-toggle[aria-pressed=\"false\"]{opacity:.60;}",
401
435
  ".smap-btn:disabled{opacity:.5;cursor:default;}",
402
- ".smap-btn-primary{border-color:rgba(150,170,230,.55);background:rgba(122,158,255,.20);}",
436
+ ".smap-btn-primary{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));}",
403
437
  ".smap-wrap{position:relative;flex:1;min-height:0;}",
404
438
  ".smap-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;border-radius:10px;cursor:crosshair;}",
405
- ".smap-tip{position:absolute;z-index:5;pointer-events:none;max-width:280px;background:rgba(10,14,30,.95);border:1px solid rgba(150,170,230,.35);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.55;box-shadow:0 6px 24px rgba(0,0,0,.45);}",
439
+ ".smap-tip{position:absolute;z-index:5;pointer-events:none;max-width:280px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.55;box-shadow:0 6px 24px rgba(0,0,0,.45);}",
406
440
  ".smap-tip-title{font-weight:600;margin-bottom:2px;}",
407
- ".smap-tip-kind{opacity:.6;font-size:11px;margin-bottom:2px;}",
408
- ".smap-tip-desc{opacity:.85;}",
409
- ".smap-panel{position:absolute;top:10px;right:10px;bottom:10px;width:min(400px,54%);background:rgba(9,13,28,.97);border:1px solid rgba(150,170,230,.32);border-radius:10px;padding:12px 14px;overflow-y:auto;z-index:6;font-size:13px;line-height:1.7;box-shadow:-8px 0 30px rgba(0,0,0,.35);}",
441
+ ".smap-tip-kind{color:var(--dsw-alias-label-secondary);font-size:11px;margin-bottom:2px;}",
442
+ ".smap-tip-desc{color:var(--dsw-alias-label-secondary);}",
443
+ ".smap-panel{position:absolute;top:10px;right:10px;bottom:10px;width:min(400px,54%);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px 14px;overflow-y:auto;z-index:6;font-size:13px;line-height:1.7;box-shadow:-8px 0 30px rgba(0,0,0,.35);}",
410
444
  ".smap-panel-head{display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;}",
411
445
  ".smap-panel-title{font-weight:600;font-size:14px;flex:1;}",
412
- ".smap-panel-meta{font-size:11px;opacity:.6;margin-bottom:8px;}",
413
- ".smap-panel-arch{display:flex;flex-direction:column;gap:6px;border:1px solid rgba(150,170,230,.28);border-radius:8px;padding:8px 10px;background:rgba(255,255,255,.03);}",
446
+ ".smap-panel-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:8px;}",
447
+ ".smap-panel-arch{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);}",
414
448
  ".smap-meta-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;vertical-align:-1px;}",
415
449
  ".smap-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px;font-size:12px;align-items:center;}",
416
450
  ".smap-lg-item{cursor:pointer;user-select:none;}",
417
451
  ".smap-lg-item.off{opacity:.32;}",
418
452
  ".smap-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:-1px;}",
419
- ".smap-count{margin-left:auto;opacity:.55;font-size:11px;}",
420
- ".smap-status{padding:28px;text-align:center;opacity:.6;font-size:13px;}",
421
- ".smap-empty{position:absolute;left:0;right:0;top:46%;text-align:center;color:rgba(219,228,245,.42);font-size:13px;pointer-events:none;z-index:4;}",
422
- ".smap-replay-on{border-color:rgba(255,210,125,.55)!important;color:#ffd27d!important;}",
423
- ".smap-replay-date{font-size:11px;opacity:.75;color:#ffd27d;font-variant-numeric:tabular-nums;}",
453
+ ".smap-count{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:11px;}",
454
+ ".smap-status{padding:28px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px;}",
455
+ ".smap-empty{position:absolute;left:0;right:0;top:46%;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:13px;pointer-events:none;z-index:4;}",
456
+ ".smap-replay-on{border-color:var(--dsw-alias-brand-primary)!important;color:var(--dsw-alias-brand-primary)!important;}",
457
+ ".smap-replay-date{font-size:11px;color:var(--dsw-alias-brand-primary);font-variant-numeric:tabular-nums;}",
424
458
  ".smap-md-h{font-weight:600;margin:12px 0 4px;}",
425
459
  ".smap-md-h1{font-size:16px;}",
426
460
  ".smap-md-h2{font-size:14.5px;}",
427
461
  ".smap-md-h3,.smap-md-h4{font-size:13.5px;opacity:.92;}",
428
462
  ".smap-md-p{margin:4px 0;}",
429
463
  ".smap-md-list{margin:4px 0;padding-left:20px;}",
430
- ".smap-md-code{background:rgba(255,255,255,.09);padding:1px 5px;border-radius:4px;font-size:12px;}",
431
- ".smap-md-pre{background:rgba(255,255,255,.06);padding:8px 10px;border-radius:8px;font-size:12px;overflow-x:auto;white-space:pre-wrap;margin:6px 0;}",
432
- ".smap-md-quote{border-left:3px solid rgba(150,170,230,.4);margin:6px 0;padding:2px 10px;opacity:.88;}",
433
- ".smap-md-wiki{color:#8ab4ff;}",
434
- ".smap-md-hr{border:none;border-top:1px solid rgba(150,170,230,.22);margin:10px 0;}",
435
- ".smap-viewtoggle{display:inline-flex;border:1px solid rgba(150,170,230,.25);border-radius:8px;overflow:hidden;}",
436
- ".smap-vt-btn{background:transparent;border:none;color:rgba(219,228,245,.55);font-size:12px;padding:4px 10px;cursor:pointer;font-family:inherit;}",
437
- ".smap-vt-btn.on{background:rgba(255,255,255,.12);color:#dbe4f5;}",
464
+ ".smap-md-code{background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));padding:1px 5px;border-radius:4px;font-size:12px;}",
465
+ ".smap-md-pre{background:var(--dsw-alias-bg-layer-2);padding:8px 10px;border-radius:8px;font-size:12px;overflow-x:auto;white-space:pre-wrap;margin:6px 0;}",
466
+ ".smap-md-quote{border-left:3px solid var(--dsw-alias-border-l2);margin:6px 0;padding:2px 10px;color:var(--dsw-alias-label-secondary);}",
467
+ ".smap-md-wiki{color:var(--dsw-alias-brand-primary);}",
468
+ ".smap-md-hr{border:none;border-top:1px solid var(--dsw-alias-border-l1);margin:10px 0;}",
469
+ ".smap-viewtoggle{display:inline-flex;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;}",
470
+ ".smap-vt-btn{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:12px;padding:4px 10px;cursor:pointer;font-family:inherit;}",
471
+ ".smap-vt-btn.on{background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-primary);}",
438
472
  ".smap-timeline{position:absolute;inset:0;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:4px 6px 4px 2px;}",
439
- ".smap-tl-item{display:flex;align-items:baseline;gap:8px;background:rgba(255,255,255,.035);border:1px solid rgba(150,170,230,.16);border-radius:8px;padding:7px 10px;cursor:pointer;flex:none;}",
440
- ".smap-tl-item:hover{background:rgba(255,255,255,.08);border-color:rgba(150,170,230,.35);}",
473
+ ".smap-tl-item{display:flex;align-items:baseline;gap:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px 10px;cursor:pointer;flex:none;}",
474
+ ".smap-tl-item:hover{background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-border-l1);}",
441
475
  ".smap-tl-item--arch{opacity:.75;}",
442
- ".smap-ar-badge{font-size:10.5px;opacity:.65;border:1px solid rgba(150,170,230,.3);border-radius:5px;padding:0 5px;flex:none;align-self:center;}",
443
- ".smap-tl-date{font-size:11px;opacity:.5;font-variant-numeric:tabular-nums;flex:none;}",
476
+ ".smap-ar-badge{font-size:10.5px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:5px;padding:0 5px;flex:none;align-self:center;}",
477
+ ".smap-tl-date{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none;}",
444
478
  ".smap-tl-title{font-size:13px;font-weight:600;flex:none;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
445
- ".smap-tl-desc{font-size:12px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}",
479
+ ".smap-tl-desc{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}",
446
480
  ].join("\n");
447
481
 
448
482
  function injectCss() {
@@ -463,14 +497,15 @@ const StarmapRuntime = (function (require, _react) {
463
497
  }
464
498
 
465
499
  // 归档星(暗星)的用色:把类型色往石板灰上压 —— 它得「还在图上、一眼数得出来」,
466
- // 又要一眼看出不是活记忆。亮度靠 shader 那一档(0.52),这里负责色相:冷、灰、不饱和。
467
- const ARCHIVE_GREY = [0.46, 0.50, 0.58];
500
+ // 又要一眼看出不是活记忆。亮度靠 shader 那一档(0.52),这里负责色相:
501
+ // 深色档是冷石板灰;浅色档换成暖灰,否则灰点落在纸上看不见。
468
502
  function dimRgb(hex) {
469
503
  const c = hexToRgb(hex);
504
+ const g = starmapPalette().archive;
470
505
  return [
471
- c[0] * 0.26 + ARCHIVE_GREY[0] * 0.74,
472
- c[1] * 0.26 + ARCHIVE_GREY[1] * 0.74,
473
- c[2] * 0.26 + ARCHIVE_GREY[2] * 0.74,
506
+ c[0] * 0.26 + g[0] * 0.74,
507
+ c[1] * 0.26 + g[1] * 0.74,
508
+ c[2] * 0.26 + g[2] * 0.74,
474
509
  ];
475
510
  }
476
511
  function dimCss(hex) {
@@ -483,17 +518,21 @@ const StarmapRuntime = (function (require, _react) {
483
518
  // 「会呼吸的彩色底」正是廉价感的一大来源,而且每帧都要重算噪声。
484
519
  // 现在只剩一条静态径向渐变:中心略亮的中性深灰,边缘更暗。uTime 保留声明
485
520
  // 只为让 uniform 列表不变(用不到时 getUniformLocation 返回 null,调用是安全空操作)。
486
- const SMAP_BG_FS = [
487
- "precision highp float;",
488
- "uniform vec2 uRes;",
489
- "uniform float uTime;",
490
- "void main(){",
491
- " vec2 suv = gl_FragCoord.xy / uRes;",
492
- " float r = length((suv - vec2(0.5, 0.44)) * vec2(1.0, 1.28));",
493
- " vec3 col = mix(vec3(0.105, 0.117, 0.145), vec3(0.043, 0.050, 0.065), clamp(r * 1.30, 0.0, 1.0));",
494
- " gl_FragColor = vec4(col, 1.0);",
495
- "}",
496
- ].join("\n");
521
+ // 底色由调色板给(深色档=夜空深灰,浅色档=暖纸),所以是函数不是常量。
522
+ function smapBgFs(inner, outer) {
523
+ const v = (c) => c.map((x) => x.toFixed(3)).join(", ");
524
+ return [
525
+ "precision highp float;",
526
+ "uniform vec2 uRes;",
527
+ "uniform float uTime;",
528
+ "void main(){",
529
+ " vec2 suv = gl_FragCoord.xy / uRes;",
530
+ " float r = length((suv - vec2(0.5, 0.44)) * vec2(1.0, 1.28));",
531
+ " vec3 col = mix(vec3(" + v(inner) + "), vec3(" + v(outer) + "), clamp(r * 1.30, 0.0, 1.0));",
532
+ " gl_FragColor = vec4(col, 1.0);",
533
+ "}",
534
+ ].join("\n");
535
+ }
497
536
 
498
537
  const SMAP_STAR_VS = [
499
538
  "attribute vec2 aCorner;",
@@ -642,7 +681,8 @@ const StarmapRuntime = (function (require, _react) {
642
681
  canvasEl.width = W * 2;
643
682
  canvasEl.height = H * 2;
644
683
  gl.viewport(0, 0, canvasEl.width, canvasEl.height);
645
- const progBg = glProgram(gl, SMAP_BG_VS, SMAP_BG_FS, ["aPos"], ["uRes", "uTime"]);
684
+ const pal = starmapPalette();
685
+ const progBg = glProgram(gl, SMAP_BG_VS, smapBgFs(pal.bgGl[0], pal.bgGl[1]), ["aPos"], ["uRes", "uTime"]);
646
686
  const progStar = glProgram(gl, SMAP_STAR_VS, SMAP_STAR_FS, ["aCorner", "aMeta0", "aColor", "aMeta1", "aBirth"], ["uRes", "uTime", "uHoverIdx", "uRevealT"]);
647
687
  const progLine = glProgram(gl, SMAP_LINE_VS, SMAP_LINE_FS, ["aPos", "aColor", "aBirth"], ["uRes", "uRevealT"]);
648
688
 
@@ -660,7 +700,7 @@ const StarmapRuntime = (function (require, _react) {
660
700
  const x = rand01(i * 97 + 13) * W;
661
701
  const y = rand01(i * 31 + 7) * H;
662
702
  const r = 2.4 + rand01(i * 17 + 5) * 2.0;
663
- pushStarQuad(arr, x, y, r, [0.62, 0.70, 0.87], 0.10 + 0.30 * rand01(i * 7 + 3), rand01(i * 11 + 29), 0, -1, -1);
703
+ pushStarQuad(arr, x, y, r, hexToRgb(pal.dust), 0.10 + 0.30 * rand01(i * 7 + 3), rand01(i * 11 + 29), 0, -1, -1);
664
704
  }
665
705
  gl.bindBuffer(gl.ARRAY_BUFFER, dustBuf);
666
706
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arr), gl.STATIC_DRAW);
@@ -691,10 +731,10 @@ const StarmapRuntime = (function (require, _react) {
691
731
  // 负责「深空底上一眼数得出来」——只画一个小点是数不出来的;
692
732
  // ② 一颗小核心(走普通分支,fresh 传 -1 = 归档档 0.52),负责
693
733
  // 「看得出是颗星」,并且 hover 时整颗点亮 + 出环。
694
- pushStarQuad(arr, p.x, p.y, p.r * 6.0, dimRgb(meta.color), 0.26, p.tw, 0, -1, birth);
695
- pushStarQuad(arr, p.x, p.y, p.r * 4.4, dimRgb(meta.color), 1, p.tw, -1, i, birth);
734
+ pushStarQuad(arr, p.x, p.y, p.r * 6.0, dimRgb(kindColor(meta)), 0.26, p.tw, 0, -1, birth);
735
+ pushStarQuad(arr, p.x, p.y, p.r * 4.4, dimRgb(kindColor(meta)), 1, p.tw, -1, i, birth);
696
736
  } else {
697
- pushStarQuad(arr, p.x, p.y, p.r * 5.4, hexToRgb(meta.color), 1, p.tw, fresh, i, birth);
737
+ pushStarQuad(arr, p.x, p.y, p.r * 5.4, hexToRgb(kindColor(meta)), 1, p.tw, fresh, i, birth);
698
738
  }
699
739
  }
700
740
  gl.bindBuffer(gl.ARRAY_BUFFER, starBuf);
@@ -709,7 +749,7 @@ const StarmapRuntime = (function (require, _react) {
709
749
  const ln = visBox.lines[li];
710
750
  const a = layout.pts[ln[0]];
711
751
  const b = layout.pts[ln[1]];
712
- const c = hexToRgb((KIND_META[ln[2]] || KIND_META.special).color);
752
+ const c = hexToRgb(kindColor(KIND_META[ln[2]] || KIND_META.special));
713
753
  const hot = hoverLineIdx >= 0 && (ln[0] === hoverLineIdx || ln[1] === hoverLineIdx);
714
754
  const mul = hot ? 1 : 0.72;
715
755
  const al = hot ? 0.6 : 0.22;
@@ -801,16 +841,25 @@ const StarmapRuntime = (function (require, _react) {
801
841
  const c2d = canvasEl.getContext("2d");
802
842
  if (!c2d) return;
803
843
  c2d.setTransform(SCALE, 0, 0, SCALE, 0, 0);
844
+ const pal = starmapPalette();
804
845
  const bg = c2d.createLinearGradient(0, 0, 0, H);
805
- bg.addColorStop(0, "#0b1026");
806
- bg.addColorStop(1, "#101731");
846
+ bg.addColorStop(0, pal.bg2d[0]);
847
+ bg.addColorStop(1, pal.bg2d[1]);
807
848
  c2d.fillStyle = bg;
808
849
  c2d.fillRect(0, 0, W, H);
850
+ // 晕染:中心透、边缘略深(浅色档才要,深色档的夜空渐变已经够了)
851
+ if (pal.vignette) {
852
+ const vg = c2d.createRadialGradient(W * 0.5, H * 0.46, 0, W * 0.5, H * 0.46, Math.max(W, H) * 0.72);
853
+ vg.addColorStop(0, hexA(pal.vignette.color, 0));
854
+ vg.addColorStop(1, hexA(pal.vignette.color, pal.vignette.a));
855
+ c2d.fillStyle = vg;
856
+ c2d.fillRect(0, 0, W, H);
857
+ }
809
858
  for (let i = 0; i < 150; i++) {
810
859
  const x = rand01(i * 97 + 13) * W;
811
860
  const y = rand01(i * 31 + 7) * H;
812
861
  c2d.globalAlpha = 0.08 + 0.22 * rand01(i * 7 + 3);
813
- c2d.fillStyle = "#9fb3dd";
862
+ c2d.fillStyle = pal.dust;
814
863
  c2d.fillRect(x, y, 1, 1);
815
864
  }
816
865
  c2d.globalAlpha = 1;
@@ -827,7 +876,7 @@ const StarmapRuntime = (function (require, _react) {
827
876
  lrv = lt * lt * (3 - 2 * lt);
828
877
  if (lrv <= 0) continue;
829
878
  }
830
- c2d.strokeStyle = hexA(meta.color, (hot ? 0.6 : 0.22) * lrv);
879
+ c2d.strokeStyle = hexA(kindColor(meta), (hot ? 0.6 : 0.22) * lrv);
831
880
  c2d.lineWidth = 1;
832
881
  c2d.beginPath();
833
882
  c2d.moveTo(a.x, a.y);
@@ -849,7 +898,7 @@ const StarmapRuntime = (function (require, _react) {
849
898
  // 暗星(canvas2d 兜底路径):与 webgl 同一档 —— 核心小而暗、外面一圈很淡的冷辉光。
850
899
  // 有辉光才「数得出来」;hover 时整颗点亮 + 描边环,这是它能被发现的关键。
851
900
  const hot = hoverBox.idx === i;
852
- const dc = dimRgb(meta.color);
901
+ const dc = dimRgb(kindColor(meta));
853
902
  const rgba = (a) => "rgba(" + Math.round(dc[0] * 255) + "," + Math.round(dc[1] * 255) + "," + Math.round(dc[2] * 255) + "," + a.toFixed(3) + ")";
854
903
  const haloR = p.r * 3.4;
855
904
  const hg = c2d.createRadialGradient(p.x, p.y, 0, p.x, p.y, haloR);
@@ -860,13 +909,13 @@ const StarmapRuntime = (function (require, _react) {
860
909
  c2d.arc(p.x, p.y, haloR, 0, Math.PI * 2);
861
910
  c2d.fill();
862
911
  c2d.globalAlpha = (hot ? 0.95 : 0.52) * rv;
863
- c2d.fillStyle = dimCss(meta.color);
912
+ c2d.fillStyle = dimCss(kindColor(meta));
864
913
  c2d.beginPath();
865
914
  c2d.arc(p.x, p.y, Math.max(1.2, p.r * 0.7), 0, Math.PI * 2);
866
915
  c2d.fill();
867
916
  c2d.globalAlpha = 1;
868
917
  if (hot) {
869
- c2d.strokeStyle = dimCss(meta.color);
918
+ c2d.strokeStyle = dimCss(kindColor(meta));
870
919
  c2d.lineWidth = 1;
871
920
  c2d.beginPath();
872
921
  c2d.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
@@ -876,7 +925,7 @@ const StarmapRuntime = (function (require, _react) {
876
925
  }
877
926
  if (p.star.mtimeMs > 0 && nowMs - p.star.mtimeMs < FRESH_MS) {
878
927
  const pr = p.r + 5 + 3 * (0.5 + 0.5 * Math.sin(phase * 1.4 + p.tw * 6.28));
879
- c2d.strokeStyle = hexA(meta.color, 0.4 * rv);
928
+ c2d.strokeStyle = hexA(kindColor(meta), 0.4 * rv);
880
929
  c2d.lineWidth = 1;
881
930
  c2d.beginPath();
882
931
  c2d.arc(p.x, p.y, pr, 0, Math.PI * 2);
@@ -884,20 +933,20 @@ const StarmapRuntime = (function (require, _react) {
884
933
  }
885
934
  const glowR = p.r * 4.2;
886
935
  const g = c2d.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
887
- g.addColorStop(0, hexA(meta.color, 0.5 * tw * rv));
888
- g.addColorStop(1, hexA(meta.color, 0));
936
+ g.addColorStop(0, hexA(kindColor(meta), 0.5 * tw * rv));
937
+ g.addColorStop(1, hexA(kindColor(meta), 0));
889
938
  c2d.fillStyle = g;
890
939
  c2d.beginPath();
891
940
  c2d.arc(p.x, p.y, glowR, 0, Math.PI * 2);
892
941
  c2d.fill();
893
942
  c2d.globalAlpha = (0.62 + 0.38 * tw) * rv;
894
- c2d.fillStyle = meta.color;
943
+ c2d.fillStyle = kindColor(meta);
895
944
  c2d.beginPath();
896
945
  c2d.arc(p.x, p.y, p.r, 0, Math.PI * 2);
897
946
  c2d.fill();
898
947
  c2d.globalAlpha = 1;
899
948
  if (hoverBox.idx === i) {
900
- c2d.strokeStyle = hexA(meta.color, 0.9 * rv);
949
+ c2d.strokeStyle = hexA(kindColor(meta), 0.9 * rv);
901
950
  c2d.lineWidth = 1;
902
951
  c2d.beginPath();
903
952
  c2d.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
@@ -978,6 +1027,20 @@ const StarmapRuntime = (function (require, _react) {
978
1027
  const drawBox = react.useRef({ renderer: null, t0: 0, markDirty: null }).current;
979
1028
  const replayBox = react.useRef({ raf: 0, active: false }).current;
980
1029
 
1030
+ // 宿主主题:切主题要把画布重建一遍(底色/星点色都变了),所以它是个 state,
1031
+ // 并进渲染 effect 的依赖。挂 observer 而不是每次 render 读属性 —— 属性变了要主动重画。
1032
+ const themeKeyS = react.useState(isDarkTheme() ? "dark" : "light");
1033
+ const themeKey = themeKeyS[0];
1034
+ const setThemeKey = themeKeyS[1];
1035
+ react.useEffect(() => {
1036
+ if (typeof document === "undefined" || !document.body) return undefined;
1037
+ const sync = () => setThemeKey(isDarkTheme() ? "dark" : "light");
1038
+ sync();
1039
+ const mo = new MutationObserver(sync);
1040
+ mo.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1041
+ return () => mo.disconnect();
1042
+ }, []);
1043
+
981
1044
  // Esc 分层:详情面板开着时先关面板(capture 阶段拦截),再按才落到 overlay 关星空。
982
1045
  react.useEffect(() => {
983
1046
  if (!selected) return undefined;
@@ -1077,7 +1140,7 @@ const StarmapRuntime = (function (require, _react) {
1077
1140
  window.cancelAnimationFrame(replayBox.raf);
1078
1141
  if (renderer.destroy) renderer.destroy();
1079
1142
  };
1080
- }, [data, layout, canvasEl]);
1143
+ }, [data, layout, canvasEl, themeKey]);
1081
1144
 
1082
1145
  react.useEffect(() => {
1083
1146
  if (!data || !layout || !kindsOn) return;
@@ -1285,7 +1348,7 @@ const StarmapRuntime = (function (require, _react) {
1285
1348
  onClick: () => toggleKind(k),
1286
1349
  title: "点击显示/隐藏这一类",
1287
1350
  },
1288
- h("span", { className: "smap-dot", style: { background: KIND_META[k].color } }),
1351
+ h("span", { className: "smap-dot", style: { background: kindColor(KIND_META[k]) } }),
1289
1352
  KIND_META[k].label + " " + n));
1290
1353
  }
1291
1354
  let totalBytes = 0;
@@ -1327,7 +1390,7 @@ const StarmapRuntime = (function (require, _react) {
1327
1390
  h("span", { className: "smap-panel-title" }, selected.star.title),
1328
1391
  h("button", { className: "smap-btn", onClick: () => setSelected(null), title: "关闭" }, "×")),
1329
1392
  h("div", { key: "meta", className: "smap-panel-meta" },
1330
- h("span", { className: "smap-meta-dot", style: { background: meta.color } }),
1393
+ h("span", { className: "smap-meta-dot", style: { background: kindColor(meta) } }),
1331
1394
  meta.label + " · " + selected.star.file + " · " + fmtBytes(selected.star.bytes) +
1332
1395
  // 归档星的 mtime 是「当年写下」的时间,跟「什么时候收起来的」不是一回事 ——
1333
1396
  // 这里只留「已归档」,具体时间在下面的归档块里给。
@@ -1370,7 +1433,7 @@ const StarmapRuntime = (function (require, _react) {
1370
1433
  onClick: () => openStar(s),
1371
1434
  title: s.archived === true ? "归档案:点开看归档信息并恢复" : "展开全文",
1372
1435
  },
1373
- h("span", { className: "smap-meta-dot", style: { background: s.archived === true ? dimCss((KIND_META[s.kind] || KIND_META.special).color) : (KIND_META[s.kind] || KIND_META.special).color, flex: "none", alignSelf: "center" } }),
1436
+ h("span", { className: "smap-meta-dot", style: { background: s.archived === true ? dimCss(kindColor(KIND_META[s.kind] || KIND_META.special)) : kindColor(KIND_META[s.kind] || KIND_META.special), flex: "none", alignSelf: "center" } }),
1374
1437
  s.archived === true ? h("span", { className: "smap-ar-badge" }, "已归档") : null,
1375
1438
  h("span", { className: "smap-tl-date", title: s.mtimeMs ? fmtDate(s.mtimeMs) : "" }, s.mtimeMs ? fmtRel(s.mtimeMs) : "—"),
1376
1439
  h("span", { className: "smap-tl-title" }, s.title),
@@ -2016,7 +2079,7 @@ const StarmapRuntime = (function (require, _react) {
2016
2079
  // 而独立成屏后如果父容器不是 column flex,`align-items:stretch` 就不会发生。
2017
2080
  ".smem-tool-panel--full{width:100%;align-self:stretch;box-sizing:border-box;}",
2018
2081
  // 已锁定的记忆:卡片上那个按钮要一眼看出「这条现在拒绝归档」,用警示色
2019
- ".smem-btn-locked{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);}",
2082
+ ".smem-btn-locked{border-color:var(--dsw-alias-state-warn-primary,#d97706);color:var(--dsw-alias-state-warn-primary,#d97706);}",
2020
2083
  // 「自动归档」页签里的天数输入框:窄一点,别让三个数字各占一整行
2021
2084
  ".smem-num{width:88px;}",
2022
2085
  // 体检 / 候选是「翻清单」的面板:定高 + 内部滚动,列表不被顶出首屏。
@@ -2105,7 +2168,7 @@ const StarmapRuntime = (function (require, _react) {
2105
2168
  ".smem-ad-main{flex:1;min-width:140px;overflow-wrap:anywhere;}",
2106
2169
  ".smem-ad-sub{color:var(--dsw-alias-label-secondary);font-size:11.5px;overflow-wrap:anywhere;}",
2107
2170
  ".smem-ad-badge{display:inline-flex;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:0 5px;white-space:nowrap;}",
2108
- ".smem-ad-badge--warn{color:var(--dsw-alias-state-warning-primary,#d97706);border-color:var(--dsw-alias-state-warning-primary,#d97706);}",
2171
+ ".smem-ad-badge--warn{color:var(--dsw-alias-state-warn-primary,#d97706);border-color:var(--dsw-alias-state-warn-primary,#d97706);}",
2109
2172
  ".smem-ad-badge--legacy{opacity:.78;}",
2110
2173
  ".smem-ad-confirm{border:1px solid var(--dsw-alias-state-error-primary,#ef4444);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.55;display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere;}",
2111
2174
  ".smem-ad-rb{font-size:12px;line-height:1.5;overflow-wrap:anywhere;}",
@@ -2114,7 +2177,7 @@ const StarmapRuntime = (function (require, _react) {
2114
2177
  '.smem-ad-run[data-on="1"]{border-color:var(--dsw-alias-brand-primary,#4d76e6);}',
2115
2178
  ".smem-ad-md{max-height:360px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6;}",
2116
2179
  // ── 注入参数 / 保留名名单(自动做梦 tab)──
2117
- ".smem-ad-warn{color:var(--dsw-alias-state-warning-primary,#d97706);}",
2180
+ ".smem-ad-warn{color:var(--dsw-alias-state-warn-primary,#d97706);}",
2118
2181
  ".smem-chip--fixed{opacity:.72;cursor:default;}",
2119
2182
  ".smem-chip-x{border:0;background:transparent;color:inherit;cursor:pointer;font-size:11px;font-family:inherit;padding:0 0 0 5px;}",
2120
2183
  // ── 更新提示(有新版本才出现的一条窄横幅)──
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, p
23
23
  import { isReserved, nameKey, safeName } from './memory/naming.js'
24
24
  import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
25
25
  import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
26
+ import { rankChunks } from './memory/search.js'
26
27
  import { archiveMemory, restoreMemory } from './memory/archive.js'
27
28
  import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
28
29
  import { warnLog, writeFileAtomic } from './memory/util.js'
@@ -48,6 +49,24 @@ function debugRecall(payload) {
48
49
  console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
49
50
  }
50
51
 
52
+ /**
53
+ * 按文件去重,保留每个文件**首次**(也就是得分最高)出现的那条正文命中。
54
+ *
55
+ * 正文检索是段落粒度的,同一份文件可能返回多个段落。注入链要的是「这份文件要不要端上来」,
56
+ * 所以在这里收成文件粒度 —— 而**不是**把同一文件的多个段落一起端上去:那正是
57
+ * `retrieval.js` 当初从「全文匹配」退回「只比摘要」要躲开的病(长文靠段数多占满候选位)。
58
+ */
59
+ function dedupeByFile(hits) {
60
+ const seen = new Set()
61
+ const out = []
62
+ for (const h of hits) {
63
+ if (seen.has(h.file)) continue
64
+ seen.add(h.file)
65
+ out.push(h)
66
+ }
67
+ return out
68
+ }
69
+
51
70
  /**
52
71
  * 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
53
72
  * 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
@@ -280,11 +299,46 @@ export class MemoryGateway extends TypertRemoteService {
280
299
  // 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
281
300
  // 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
282
301
  const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
283
- const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
302
+
303
+ // ── 正文检索 ──
304
+ // 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
305
+ // 这里用它做两件事:
306
+ // 1. 给候选文件定位「最相关的那一段」,注入时替代「文件开头 1500 字」——
307
+ // 旧写法对任何问题都端同一段开头,等于把「命中在哪」这件事扔掉了;
308
+ // 2. 摘要一条都没命中时,用正文兜一次底。此前这种情况直接等于「没有记忆可用」。
309
+ //
310
+ // query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
311
+ // 命中的全是「继续」这个词本身,与用户问什么毫无关系。
312
+ const bodyRanked = typeof text === 'string' && text.length >= 4
313
+ ? rankChunks(text, files, { limit: MAX_RESULTS, perFile: 1 })
314
+ : []
315
+ const bestChunk = new Map()
316
+ for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
317
+
318
+ // 「摘要命中就按摘要排;一条都没有才让正文接手」这个**保守写法是错的**,
319
+ // 由评测数据推翻(scripts/eval-retrieval.mjs 的对照 + 注入链实测):
320
+ // 摘要只要命中**任何一条**(哪怕很弱、甚至命中错的文件),正文里那份真正相关的
321
+ // 就再也没有机会——应注入的文件级召回从 87.0% 掉到 60.9%。而噪音并没有因此更低
322
+ // (正文命中本来就要过相关性门槛,实测误召回 1/10,与保守写法持平)。
323
+ //
324
+ // 所以改成**并集**:摘要命中的在前(它经过人工写的 description,精确度高),
325
+ // 正文独有的在后(按 BM25 + 证据强度排序)。`take` 按文件去重,
326
+ // 同一份文件不会因为两条检索线都命中而占两个名额。
327
+ const byFile = new Map(files.map(f => [f.file, f]))
328
+ const selected = relevant.slice()
329
+ const taken = new Set(selected.map(f => f.file))
330
+ for (const h of dedupeByFile(bodyRanked)) {
331
+ if (taken.has(h.file)) continue
332
+ taken.add(h.file)
333
+ const f = byFile.get(h.file)
334
+ if (f) selected.push(f)
335
+ }
336
+
337
+ const signature = text + '\u0000' + selected.map(f => f.file).join(',')
284
338
  if (recallCache.get(session.id) !== signature) {
285
339
  recallCache.set(session.id, signature)
286
340
  if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
287
- relevant.forEach(take)
341
+ selected.forEach(take)
288
342
  }
289
343
 
290
344
  if (picked.length === 0) {
@@ -305,12 +359,24 @@ export class MemoryGateway extends TypertRemoteService {
305
359
  const injectedFiles = []
306
360
  let skippedForBudget = 0
307
361
  for (const f of picked) {
308
- const body = f.content.length > MAX_CHARS_PER_FILE
309
- ? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
310
- : f.content
362
+ // **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
363
+ // (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
364
+ // 把「完整在场」偷偷降级成「命中在场」—— 那正是 baseline 要防的事。
365
+ const chunk = f.baseline ? null : bestChunk.get(f.file)
366
+ // 命中段落优先;没有命中(baseline、或 query 太短没做正文检索)才回到「文件开头」。
367
+ // 旧写法对**任何**问题都端同一段开头,等于把「命中在哪」这个信息扔掉了。
368
+ const raw = chunk ? chunk.text : f.content
369
+ const body = raw.length > MAX_CHARS_PER_FILE
370
+ ? raw.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
371
+ : raw
311
372
  const days = ageDays(f, nowMs)
312
373
  const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
313
- const part = `### ${f.file}${stamp}\n${body}`
374
+ // 段落命中时补两样东西:
375
+ // - 行号:模型据此能直接用文件工具跳过去核对来源,「引用可核对」靠它成立;
376
+ // - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
377
+ const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
378
+ const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
379
+ const part = `### ${f.file}${loc}${stamp}${brief}\n${body}`
314
380
  const size = Buffer.byteLength(part, 'utf8')
315
381
  if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
316
382
  usedBytes += size