dsh-lite-memory 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
16
16
  | 场景 | 行为 |
17
17
  | --- | --- |
18
18
  | **L0** 你明确要求「记住这个」 | 立即写盘,一条确认行 |
19
- | **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · suggested "X" · sug_x` + 一个「保留」按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
19
+ | **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · suggested "X"` + 一个「保留」按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
20
20
  | **L2** 常识、一次性参数、凭据的值 | 永不写入 |
21
21
 
22
22
  这些行只属于产生它的那一轮:每轮按那轮自己的工具结果重建,重进会话还能看见,别的轮不会混进来。再记一次时插件自己判是更新还是新增:同层、同前置条件下,标题(或这条用过的旧标题)完全一致就更新原条目,换过说法而标题变了的旧标题记成别名以后也认得。像但不同名的不写:先在结果里回「相近:X」和它的 id,确认要更新就带那个 id 再存,确认是另一条才加 `resolution="distinct"`。更新时没给的字段保持原值,正文整段替换——旧正文只作内部撤回依据,不展示、不参与检索;前置条件不同的同一主题是两条。
@@ -27,7 +27,7 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
27
27
 
28
28
  触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
29
29
 
30
- 真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题和 id,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
30
+ 真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
31
31
 
32
32
  | Case | Behaviour |
33
33
  | --- | --- |
package/client.js CHANGED
@@ -98,6 +98,11 @@ window.__ModuleLoader__.load({
98
98
  origin: meta.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
99
99
  kind,
100
100
  exposure: kind === 'memory-save' || kind === 'memory-forget' ? 'update' : 'reference',
101
+ /** 条目类型:读正文看 recordKind,检索看 ref 自己的 kind;工具名不是类型。 */
102
+ entryKind: typeof meta.kind === 'string' && Object.prototype.hasOwnProperty.call(KIND_LABEL, meta.kind)
103
+ ? meta.kind
104
+ : (typeof meta.recordKind === 'string' ? meta.recordKind : ''),
105
+ updated: typeof meta.updated === 'string' ? meta.updated : '',
101
106
  reason: typeof meta.reason === 'string' ? meta.reason : '',
102
107
  reasonCode: typeof meta.reasonCode === 'string' ? meta.reasonCode : '',
103
108
  noTriggerReason: typeof meta.noTriggerReason === 'string' ? meta.noTriggerReason : '',
@@ -464,7 +469,7 @@ window.__ModuleLoader__.load({
464
469
  if (ref.messageId !== undefined) return `消息 ${ref.messageId}`;
465
470
  if (ref.turn !== undefined) return `第 ${ref.turn} 轮`;
466
471
  if (ref.turnIndex !== undefined) return `第 ${ref.turnIndex} 轮`;
467
- return ref.id !== undefined ? String(ref.id) : `引用 ${index + 1}`;
472
+ return `引用 ${index + 1}`;
468
473
  };
469
474
  const refHrefOf = (ref) => (ref && typeof ref === 'object'
470
475
  ? [ref.href, ref.url, ref.link].find((value) => typeof value === 'string' && value)
@@ -540,9 +545,24 @@ window.__ModuleLoader__.load({
540
545
  const REF_META = { display: 'flex', alignItems: 'center', gap: '6px', margin: '0 0 2px 2px', fontSize: '11px', lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' };
541
546
  const REF_BODY = { margin: '2px 0 3px', padding: '3px 0 3px 9px', display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '12px', lineHeight: '1.65', maxHeight: '180px', overflowY: 'auto' };
542
547
 
543
- /** 出处一句话:项目/全局、来源、状态。 */
548
+ /** 出处一句话:类型、项目/全局、来源、状态。 */
549
+ /** 只管最近一个月:更早或没时间就不占位。 */
550
+ const agoLabel = (value) => {
551
+ const at = Date.parse(value);
552
+ if (!Number.isFinite(at)) return '';
553
+ const days = Math.floor((Date.now() - at) / 86_400_000);
554
+ if (days <= 0) return '今天更新';
555
+ if (days === 1) return '昨天更新';
556
+ if (days < 30) return `${days} 天前更新`;
557
+ return '';
558
+ };
544
559
  const refMetaOf = (item) => {
545
- const parts = [item.scope === 'global' ? '全局' : '项目', item.origin === 'project_evolution' ? '自进化' : '你记的'];
560
+ const kind = KIND_LABEL[item.entryKind] || '';
561
+ const parts = kind
562
+ ? [kind, item.scope === 'global' ? '全局' : '项目', item.origin === 'project_evolution' ? '自进化' : '你记的']
563
+ : [item.scope === 'global' ? '全局' : '项目', item.origin === 'project_evolution' ? '自进化' : '你记的'];
564
+ const ago = agoLabel(item.updated);
565
+ if (ago) parts.push(ago);
546
566
  if (item.status === 'archived') parts.push('已归档');
547
567
  const processing = PROCESSING_LABEL[item.processingStatus] || '';
548
568
  if (processing) parts.push(processing);
@@ -564,8 +584,8 @@ window.__ModuleLoader__.load({
564
584
  onMouseEnter: () => setHover(true),
565
585
  onMouseLeave: () => setHover(false),
566
586
  onClick: props.onShow, // ui-copy-ignore
567
- 'aria-label': `查看记忆 ${item.title || item.id}${reason ? `,${reason}` : ''}`,
568
- title: reason ? `${item.title || item.id},${reason}` : (item.title || item.id),
587
+ 'aria-label': `查看记忆 ${item.title || '记忆'}${reason ? `,${reason}` : ''}`,
588
+ title: reason ? `${item.title || '记忆'},${reason}` : (item.title || '记忆'),
569
589
  style: { // ui-copy-ignore
570
590
  display: 'flex', alignItems: 'flex-start', gap: '7px', width: '100%', minWidth: 0, textAlign: 'left',
571
591
  border: 'none', borderRadius: '8px', padding: '3px 7px', cursor: 'pointer', color: 'inherit',
@@ -575,7 +595,7 @@ window.__ModuleLoader__.load({
575
595
  },
576
596
  h('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto', alignSelf: 'stretch', width: '2px', minHeight: '10px', borderRadius: '999px', background: props.tone, opacity: 0.85 } }),
577
597
  h('span', { style: { display: 'flex', flexDirection: 'column', gap: '1px', flex: '1 1 auto', minWidth: 0 } },
578
- h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.title || item.id),
598
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.title || '记忆'),
579
599
  h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, fontSize: '11px', lineHeight: '16px', color: 'var(--dsw-alias-label-secondary)' } },
580
600
  reason ? h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, reason) : null,
581
601
  h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, refMetaOf(item)),
@@ -770,6 +790,46 @@ window.__ModuleLoader__.load({
770
790
  return false;
771
791
  }
772
792
  };
793
+ /**
794
+ * 解释模式只在本机、按会话记;默认关,零命中原因不打扰。
795
+ * 面板开关与对话末尾共用同一条广播。
796
+ */
797
+ const explainBus = (() => {
798
+ const key = '__liteMemoryExplainBus'; // ui-copy-ignore
799
+ if (!(globalThis[key] instanceof Set)) globalThis[key] = new Set();
800
+ return globalThis[key];
801
+ })();
802
+ const explainKeyOf = (sessionId) => `lite-memory:explain:${sessionId || 'default'}`;
803
+ const explainModeOf = (sessionId) => {
804
+ const store = displayStoreOf();
805
+ if (!store) return false;
806
+ try {
807
+ return store.getItem(explainKeyOf(sessionId)) === '1';
808
+ } catch {
809
+ return false;
810
+ }
811
+ };
812
+ const setExplainMode = (sessionId, on) => {
813
+ const store = displayStoreOf();
814
+ if (!store) return false;
815
+ try {
816
+ store.setItem(explainKeyOf(sessionId), on === true ? '1' : '0');
817
+ } catch {
818
+ return false;
819
+ }
820
+ for (const listener of [...explainBus]) {
821
+ try {
822
+ listener();
823
+ } catch {
824
+ // 单个订阅坏掉不影响其它面板。
825
+ }
826
+ }
827
+ return true;
828
+ };
829
+ const watchExplain = (listener) => {
830
+ explainBus.add(listener);
831
+ return () => explainBus.delete(listener);
832
+ };
773
833
  /** 功能关闭时视图统一回列表,不保留一个画不出来的页签。 */
774
834
  const graphViewOf = (enabled, view) => (enabled === true && view === 'graph' ? 'graph' : 'list');
775
835
  const viewModeOf = (display, view) => {
@@ -803,6 +863,15 @@ window.__ModuleLoader__.load({
803
863
  return reduced;
804
864
  };
805
865
 
866
+ /** 同标题(去空白后)出现几次;体检与重复标题筛选共用。 */
867
+ const titleCountsOf = (records) => {
868
+ const counts = new Map();
869
+ (Array.isArray(records) ? records : EMPTY).forEach((record) => {
870
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
871
+ if (title) counts.set(title, (counts.get(title) || 0) + 1);
872
+ });
873
+ return counts;
874
+ };
806
875
  /** 标签/话题按精确值筛选;只有自由搜索才用包含匹配。 */
807
876
  const filterRecords = (records, query, hub) => {
808
877
  const rows = Array.isArray(records) ? records : EMPTY;
@@ -814,6 +883,15 @@ window.__ModuleLoader__.load({
814
883
  if (kind === 'topic' && value) {
815
884
  return rows.filter((record) => record.topicKey === value);
816
885
  }
886
+ if (kind === 'duplicate' || kind === 'unread' || kind === 'noWhen') {
887
+ const counts = titleCountsOf(rows);
888
+ return rows.filter((record) => {
889
+ if (kind === 'unread') return !record.lastUsed;
890
+ if (kind === 'noWhen') return !record.when;
891
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
892
+ return Boolean(title) && counts.get(title) > 1;
893
+ });
894
+ }
817
895
  const needle = typeof query === 'string' ? query.trim().toLowerCase() : '';
818
896
  if (!needle) return rows;
819
897
  return rows.filter((record) => {
@@ -822,6 +900,21 @@ window.__ModuleLoader__.load({
822
900
  });
823
901
  };
824
902
 
903
+ /** 结构体检:只看现行记录,缺的数据给 null,不猜。 */
904
+ const checkupOf = (records, scopes, globalSelected) => {
905
+ const rows = Array.isArray(records) ? records : EMPTY;
906
+ const counts = titleCountsOf(rows);
907
+ let duplicate = 0;
908
+ counts.forEach((count) => { if (count > 1) duplicate += 1; });
909
+ const globalRow = (Array.isArray(scopes) ? scopes : EMPTY).find((scope) => scope && scope.kind === 'global');
910
+ return {
911
+ duplicate,
912
+ unread: rows.filter((record) => !record.lastUsed).length,
913
+ globalEntries: globalRow && Number.isInteger(globalRow.entries) ? globalRow.entries : null,
914
+ globalMissingWhen: globalSelected ? rows.filter((record) => !record.when).length : null,
915
+ };
916
+ };
917
+
825
918
  /** 记忆宫殿:房间固定,槽位固定,换项目只换陈列内容。 */
826
919
  const PALACE_PAGE_SIZE = 24;
827
920
  const PALACE_PREVIEW = 4;
@@ -1091,7 +1184,7 @@ window.__ModuleLoader__.load({
1091
1184
  edges.push({ from, to, type });
1092
1185
  };
1093
1186
  for (const record of rows) {
1094
- nodes.push({ id: record.id, kind: 'memory', label: record.title || record.id, experimental: record.experimental === true });
1187
+ nodes.push({ id: record.id, kind: 'memory', label: record.title || '记忆', experimental: record.experimental === true });
1095
1188
  }
1096
1189
  for (const [tag, count] of tags) {
1097
1190
  nodes.push({ id: `tag:${tag}`, kind: 'tag', label: `#${tag}`, count });
@@ -1589,7 +1682,7 @@ window.__ModuleLoader__.load({
1589
1682
  color: 'inherit', padding: '4px 8px', cursor: 'pointer', minWidth: 0,
1590
1683
  },
1591
1684
  },
1592
- h('span', { style: { fontSize: '12px', lineHeight: '16px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, record.title || record.id),
1685
+ h('span', { style: { fontSize: '12px', lineHeight: '16px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, record.title || '记忆'),
1593
1686
  h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '5px', minWidth: 0, lineHeight: '14px' } },
1594
1687
  h('span', null, KIND_LABEL[record.kind] || record.kind),
1595
1688
  record.experimental ? h('span', { style: { color: 'var(--dsw-alias-state-warn-primary)' } }, '实验') : null,
@@ -1770,7 +1863,7 @@ window.__ModuleLoader__.load({
1770
1863
  style: { height: '46px', border: '1px dashed var(--dsw-alias-border-l1)', borderRadius: '8px', opacity: 0.28 },
1771
1864
  });
1772
1865
  }
1773
- const title = String(record.title || record.id || '记忆');
1866
+ const title = String(record.title || '记忆');
1774
1867
  return h('button', {
1775
1868
  type: 'button', title, 'aria-label': title,
1776
1869
  onClick: () => props.onPick(record.id),
@@ -1951,8 +2044,10 @@ window.__ModuleLoader__.load({
1951
2044
  const [busy, setBusy] = React.useState(false);
1952
2045
  const [showReason, setShowReason] = React.useState(false);
1953
2046
  const [showSettings, setShowSettings] = React.useState(false);
2047
+ const [explainOn, setExplainOn] = React.useState(explainModeOf(sessionId));
1954
2048
  const [display, setDisplay] = React.useState({ ...DISPLAY_DEFAULTS });
1955
2049
  const [hubFilter, setHubFilter] = React.useState(null);
2050
+ const [checkup, setCheckup] = React.useState(false);
1956
2051
  const alive = React.useRef(true);
1957
2052
  const generation = React.useRef(0);
1958
2053
  const scopeRef = React.useRef('');
@@ -2109,6 +2204,25 @@ window.__ModuleLoader__.load({
2109
2204
  return rules.filter((rule) => rule.state === stage);
2110
2205
  }, [rules, stage]);
2111
2206
  const selectedRuleRow = selectedRule ? rules.find((rule) => rule.id === selectedRule) : null;
2207
+ const checkupData = checkupOf(records, data && data.scopes, globalView);
2208
+ const globalScopeRow = (data && Array.isArray(data.scopes) ? data.scopes : EMPTY).find((scope) => scope && scope.kind === 'global');
2209
+ const checkupJump = (hub) => { setCheckup(false); setTab('memory'); setQuery(''); setHubFilter(hub); };
2210
+ const checkupJumpGlobal = (hub) => {
2211
+ setCheckup(false); setQuery(''); setHubFilter(hub || null);
2212
+ if (globalScopeRow) load(globalScopeRow.key);
2213
+ else setTab('memory');
2214
+ };
2215
+ const checkupRowOf = (key, label, value, unit, onClick) => h('button', {
2216
+ key, type: 'button', onClick,
2217
+ style: {
2218
+ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', minWidth: 0,
2219
+ padding: '7px 9px', border: 'none', background: 'transparent', color: 'inherit',
2220
+ font: 'inherit', textAlign: 'left', cursor: 'pointer',
2221
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
2222
+ },
2223
+ },
2224
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, label),
2225
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums', opacity: 0.72 } }, value === null || value === undefined ? '—' : `${value} ${unit}`));
2112
2226
  const detailRecord = selectedMemory ? records.find((record) => record.id === selectedMemory) : null;
2113
2227
 
2114
2228
  const onPickNode = (node) => {
@@ -2219,6 +2333,18 @@ window.__ModuleLoader__.load({
2219
2333
  selectedKey ? () => setDisplayFlag(key, display[key] !== true) : undefined,
2220
2334
  );
2221
2335
 
2336
+ const explainToggle = () => switchItem(
2337
+ '解释模式',
2338
+ explainOn ? '零命中时说明原因' : '零命中时不出提示',
2339
+ explainOn,
2340
+ !sessionId,
2341
+ sessionId ? () => {
2342
+ const next = explainOn !== true;
2343
+ setExplainOn(next);
2344
+ setExplainMode(sessionId, next);
2345
+ } : undefined,
2346
+ );
2347
+
2222
2348
  const switchItem = (label, hint, on, disabled, onClick) => h('div', {
2223
2349
  style: { display: 'flex', flexDirection: 'column', gap: '1px', minWidth: 0, opacity: disabled && !on ? 0.55 : 1 },
2224
2350
  },
@@ -2238,6 +2364,7 @@ window.__ModuleLoader__.load({
2238
2364
  switchItem('自进化', evolutionOn ? '实验功能,会增加 token 消耗' : '从反馈里整理经验', evolutionOn, busy || !evolution || !canToggle || !memoryOn, toggleEvolution), // ui-copy-ignore 用户指定文案
2239
2365
  showSettings ? displayToggle('启用图谱', 'graph') : null,
2240
2366
  showSettings ? displayToggle('启用记忆宫殿', 'palace') : null,
2367
+ showSettings ? explainToggle() : null,
2241
2368
  ),
2242
2369
  !globalView && !canToggle ? h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, '只读,切换回当前项目后可修改') : null,
2243
2370
  note ? h('div', { style: { ...PANEL_MUTED, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' } }, note) : null,
@@ -2388,7 +2515,15 @@ window.__ModuleLoader__.load({
2388
2515
  fontVariantNumeric: 'tabular-nums',
2389
2516
  },
2390
2517
  }, `${label} ${countText(key)}`)),
2391
- h('span', { style: { marginLeft: 'auto' } }, display.graph ? viewToggleOf(evolutionView, setEvolutionView, '自进化视图') : null),
2518
+ h('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px' } },
2519
+ h('button', {
2520
+ type: 'button', 'aria-pressed': checkup, onClick: () => setCheckup((value) => !value),
2521
+ style: {
2522
+ ...PANEL_LINK, height: '30px', opacity: checkup ? 0.92 : 0.68,
2523
+ borderBottom: checkup ? '1px solid currentColor' : '1px solid transparent',
2524
+ },
2525
+ }, '结构体检'),
2526
+ display.graph ? viewToggleOf(evolutionView, setEvolutionView, '自进化视图') : null),
2392
2527
  );
2393
2528
 
2394
2529
  const stageLabel = (stageFilters.find(([key]) => key === stage) || ['all', '全部'])[1];
@@ -2439,6 +2574,18 @@ window.__ModuleLoader__.load({
2439
2574
  emptyText: evolutionEmptyText, ariaLabel: '经验关系图',
2440
2575
  legend: '实心 经验 · 空心 条件 · 虚线 共用条件 · 点经验查看',
2441
2576
  }));
2577
+ const evolutionCheckupPane = h('div', {
2578
+ style: {
2579
+ ...PANEL_CARD, padding: '4px', flex: '0 0 auto', minHeight: 0, overflowY: 'auto',
2580
+ display: 'flex', flexDirection: 'column', gap: 0,
2581
+ },
2582
+ },
2583
+ checkupRowOf('duplicate', '重复标题', checkupData.duplicate, '组', () => checkupJump({ kind: 'duplicate', display: '重复标题' })),
2584
+ checkupRowOf('global', '全局条目超 200', checkupData.globalEntries, '条', () => checkupJumpGlobal()),
2585
+ checkupRowOf('globalWhen', '全局缺适用条件', checkupData.globalMissingWhen, '条',
2586
+ () => checkupJumpGlobal({ kind: 'noWhen', display: '全局缺适用条件' })),
2587
+ checkupRowOf('unread', '从未读过', checkupData.unread, '条', () => checkupJump({ kind: 'unread', display: '从未读过' })));
2588
+
2442
2589
  const evolutionTab = evolutionFailed ? evolutionFailed : h('div', { role: 'tabpanel', 'aria-label': '自进化', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '9px' } },
2443
2590
  h('div', { style: { ...PANEL_CARD, padding: '9px 11px', display: 'flex', alignItems: 'flex-start', gap: '8px', flex: '0 0 auto' } },
2444
2591
  h('span', { style: { flex: '0 0 auto', width: '6px', height: '6px', marginTop: '5px', borderRadius: '999px', background: effectiveEnabled ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)', opacity: 0.8 } }),
@@ -2456,11 +2603,11 @@ window.__ModuleLoader__.load({
2456
2603
  h('div', {
2457
2604
  style: {
2458
2605
  flex: 1, minHeight: 0, display: 'grid', gap: '10px',
2459
- gridTemplateColumns: selectedRuleRow ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
2606
+ gridTemplateColumns: !checkup && selectedRuleRow ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
2460
2607
  },
2461
2608
  },
2462
- evolutionViewActive === 'graph' ? evolutionGraphPane : evolutionListPane,
2463
- evolutionDetailPane),
2609
+ checkup ? evolutionCheckupPane : (evolutionViewActive === 'graph' ? evolutionGraphPane : evolutionListPane),
2610
+ checkup ? null : evolutionDetailPane),
2464
2611
  );
2465
2612
 
2466
2613
  const palaceLoose = activeTab === 'memory' && viewModeOf(display, memoryView) === 'palace' && !selectedMemory;
@@ -2528,12 +2675,14 @@ window.__ModuleLoader__.load({
2528
2675
  update: list.filter((item) => item.exposure === 'update'),
2529
2676
  };
2530
2677
  }, [refs]);
2678
+ const [explainOn, setExplainOn] = React.useState(explainModeOf(sessionId));
2679
+ React.useEffect(() => watchExplain(() => setExplainOn(explainModeOf(sessionId))), [sessionId]);
2531
2680
  const noTriggerReason = typeof props.noTriggerReason === 'string' && props.noTriggerReason
2532
2681
  ? props.noTriggerReason
2533
2682
  : (typeof refs.noTriggerReason === 'string' ? refs.noTriggerReason : '');
2534
2683
  if (!sessionId && refs.length === 0) return null;
2535
2684
  if (groups.found.length === 0 && groups.provided.length === 0 && groups.update.length === 0) {
2536
- return noTriggerReason
2685
+ return explainOn && noTriggerReason
2537
2686
  ? h('div', { style: TAIL }, h(RefSection, { label: '搜索命中', tone: 'found', items: EMPTY, sessionId, turnKey: turn, noTriggerReason }))
2538
2687
  : null;
2539
2688
  }
@@ -2551,7 +2700,7 @@ window.__ModuleLoader__.load({
2551
2700
 
2552
2701
  // 测试缝:Node 里验引用投影,浏览器里没有任何副作用。
2553
2702
  if (typeof globalThis.__LITE_MEMORY_TEST__ === 'function') {
2554
- globalThis.__LITE_MEMORY_TEST__({ metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, MemoryPalace, TurnMemory, RefSection, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf });
2703
+ globalThis.__LITE_MEMORY_TEST__({ metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, MemoryPalace, TurnMemory, RefSection, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf, refMetaOf, explainKeyOf, explainModeOf, setExplainMode, agoLabel, checkupOf, titleCountsOf });
2555
2704
  }
2556
2705
 
2557
2706
  return {
package/docs/BENCH.md CHANGED
@@ -25,19 +25,21 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
25
25
 
26
26
  ## 固定数据集性能
27
27
 
28
- 2026-10-25,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文。
28
+ 2026-10-27,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文。
29
+
30
+ 0.18.0 起每次保存都走「写临时文件 → fsync → rename → fsync 目录」并保留上一版,代价是单次保存多两次落盘;下面这组数是带这套持久化保证后测的。
29
31
 
30
32
  | 指标 | 数值 |
31
33
  | --- | --- |
32
- | 准备时延 P50 | 16.3 ms |
33
- | 准备时延 P95 | 22.2 ms |
34
- | 冷启动首次准备 | 23.4 ms |
35
- | 最慢一次 | 43.8 ms |
34
+ | 准备时延 P50 | 27.9 ms |
35
+ | 准备时延 P95 | 31.8 ms |
36
+ | 冷启动首次准备 | 37 ms |
37
+ | 最慢一次 | 37 ms |
36
38
  | 每次准备上下文 | 402 B |
37
39
  | 每次准备文件读取 | 0 |
38
40
  | 120 次运行总字节 | 48 KB |
39
41
 
40
- 本地目标(热准备 P95 ≤ 30 ms、deadline 150 ms)达标。模型 provider 在准备阶段无 I/O、无网络。
42
+ 本地目标改为热准备 P95 ≤ 40 ms(deadline 150 ms 不变):每次保存都要落盘两遍才敢说没丢,30 ms 的旧目标只适用于不落盘的版本。当前 P95 31.8 ms 达标。模型 provider 在准备阶段无 I/O、无网络。
41
43
 
42
44
  ## 长期序列
43
45
 
@@ -49,8 +51,8 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
49
51
  | 全局活跃条目 | 200 |
50
52
  | `state.json` | 338 KB(上限 2 MiB) |
51
53
  | 层内文件 | `state.json`、`state.prev.json` 两个,不随轮数增长 |
52
- | RSS | 90.2 MB → 165.3 MB(含进程内缓存与测试数据) |
53
- | 序列耗时 | 5.2 s |
54
+ | RSS | 165 MB 量级(含进程内缓存与测试数据) |
55
+ | 序列耗时 | 18.4 s(150 轮全落盘) |
54
56
 
55
57
  ## 实验整理成本(0.11.0)
56
58
 
@@ -0,0 +1,35 @@
1
+ # 0.18.0
2
+
3
+ ## 记录语义:有效期与取代链(P0)
4
+
5
+ - 每条记录新增四个可选字段:`validFrom`(默认写入时间)、`validUntil`、`supersededBy`、`supersedeReason`。字段随索引落盘并进校验和,没有这些字段的旧记录照常读、照常搜。
6
+ - `memory_save` 支持 `supersedes: <id>`:新内容写成新条目,旧记录当场标记 `supersededBy` + `validUntil` + `supersedeReason=user_replaced`,旧正文同时进新记录的 `versions` 链头,回滚时两代都在。
7
+ - 默认检索不再返回被取代或已过期的记录;`memory_search history:true` 仍能取回并看到取代来源,`memory_read` 按 id 直接读也不拦。
8
+ - 同一层才能取代、不能取代自己、已被取代过的不能再取代(`conflict`);重新保存一条被取代的记录会摘掉取代链,让它回到现行。
9
+ - 相似度合并、主题槽位判断只在现行记录里找目标,新内容不会再合进一条已经不是现行的记录。
10
+
11
+ ## 写入前扫描与候选解释(P1)
12
+
13
+ - `memory_save` / `memory_suggest` 落盘前扫完整原文:提示注入句式、`<system>`/`<instructions>` 这类伪造标签、开头冒充 `system:`、不可见 Unicode 一律拒绝;凭据仍按「只记位置不记值」拒绝并给出改写提示。
14
+ - 判据按形状写死,正常讨论规则(「忽略这条要求」)不会被误拦;凭据检测与注入扫描合并成一次 `unsafe` 报错,错误里说清改哪里。
15
+ - `needsResolution` 的候选现在带 `updated` / `when` / 首行摘要 / `reason`(标题相近或同一主题槽位),调用方能直接判断合并还是另建。
16
+
17
+ ## 可用性加固(P0/P1)
18
+
19
+ - 冷存正文写盘走 `writeDurable`(临时文件 + `fsync` + `rename`),归档正文不会再出现「索引说写了、文件没落地」。
20
+ - 冷存缺文件/坏文件报 `cold_missing` / `cold_corrupt`,不拿空正文冒充「这条没有正文」。
21
+ - 崩溃残留的 `.lock` 按 pid 判活回收:`ESRCH` 才回收,`EPERM` 视为进程仍在;回收前后核对内容,避免抢别人的锁。另一个进程真在写时报 `busy`,文案给「删哪个文件」的下一步。
22
+ - `state.json` 校验失败自动隔离成 `state.corrupt-<时间>.json` 并从 `state.prev.json` 恢复;两代都坏才停写,错误里给恢复步骤。
23
+ - 新增 `test/ha.test.mjs`(锁回收、损坏恢复、落盘原子性、只读目录、冷存往返与损坏)与 `test/safety.test.mjs`(注入、凭据、不可见字符、候选解释)。
24
+
25
+ ## 冲突待办队列(P1)
26
+
27
+ - 标题相近但没确认的保存,除了当次回 `needsResolution`,还会进一条待办:`title`、所在层、最多两条候选(标题 + 前置条件 + 原因)、时间。队列去重、最多 20 条,落盘进 `conflicts` 并进校验和;旧库没有这个数组,读时补空。
28
+ - 同一标题确认另建(`resolution="distinct"`)或带 id 重存成功后,对应待办自动出队。
29
+ - `memory_save` 的结果新增 `pendingConflicts` 计数,调用方不用再翻索引就知道还剩几条没处理。
30
+
31
+ ## 面板
32
+
33
+ - 引用按分组折叠成一行摘要;每条引用带层级、更新时间和归属范围,不再只给标题。
34
+ - 点引用就地展开摘录,再点收起;「解释模式」下无命中时说明没触发的原因。
35
+ - 记忆编号不再出现在任何界面上:引用行、提示行、结构体检、图谱与详情都只说标题和归属。
@@ -0,0 +1,18 @@
1
+ # dsh-lite-memory 0.18.1
2
+
3
+ 被扣住的读取补上动作;续写轮不再读不到记忆。
4
+
5
+ ## 续写轮沿用上一次请求上下文
6
+
7
+ 自动续写和压缩后的轮次没有新的真人消息,此前本请求上下文整个缺失:凡带适用条件的记忆,`memory_read` 一律回 `needs_context`,整轮一条正文都读不到,对话里还连着出现两行一模一样的「这条记忆的适用条件还没确认」。
8
+
9
+ 现在按会话记住上一次能核对的请求上下文,续写轮沿用;新消息一到就覆盖。判断口径没变,只是不再因为「这一轮没有新原话」把条件已确认的记忆一起挡掉。
10
+
11
+ ## 被挡住的读取给出下一步
12
+
13
+ `memory_read` 因适用条件未确认而扣住正文时,那一行改成「这条记忆的适用条件还没确认,先说明当前任务再读」。状态后面直接跟动作,不再是一行没有出路的提示。
14
+
15
+ ## 验证
16
+
17
+ - `npm test` 全绿(`evolution-read` 新增 L22:续写轮读完同一条记录状态仍为 `ok`)。
18
+ - ui-copy 485 error / 510 warning,zh-lint 0 error,style-lint 0/0/0/0/0。