dsh-lite-memory 0.18.19 → 0.18.26

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/index.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  evolutionStatus,
30
30
  controlStatus,
31
31
  setMemoryEnabled,
32
+ setMemoryControl,
33
+ setRecallMode,
32
34
  isExperimentalRecord,
33
35
  setEvolution,
34
36
  taskState,
@@ -83,6 +85,9 @@ function cwdOf(execution) {
83
85
  return process.cwd();
84
86
  }
85
87
 
88
+ /** 插件配置的默认触发方式,面板要按项目实际生效值显示。 */
89
+ let hostRecallMode = 'adaptive';
90
+
86
91
  /** 本项目记忆总开关:读不出来时不擅自关掉,按开处理。 */
87
92
  function memoryEnabled(scope) {
88
93
  try {
@@ -168,6 +173,12 @@ function noteRecall(scope, sessionId, receipt) {
168
173
  while (recallReceipts.size > 64) recallReceipts.delete(recallReceipts.keys().next().value);
169
174
  }
170
175
 
176
+ /** 投影没发出去就撤销回执:面板不能显示一条模型从没见过的提醒。 */
177
+ function clearRecall(scope, sessionId) {
178
+ if (!scope || typeof sessionId !== 'string' || !sessionId) return;
179
+ recallReceipts.delete(`${scope.key}\u0000${sessionId}`);
180
+ }
181
+
171
182
  function recallReceiptOf(scope, sessionId) {
172
183
  if (!scope || typeof sessionId !== 'string' || !sessionId) return null;
173
184
  return recallReceipts.get(`${scope.key}\u0000${sessionId}`) || null;
@@ -290,6 +301,8 @@ function keepHandler(webServer, cwdForSession) {
290
301
  scope: found.scope,
291
302
  scopeLabel: found.scopeLabel || '',
292
303
  revision: found.revision,
304
+ applicability: found.applicability || 'all_in_scope',
305
+ conditions: Array.isArray(found.conditions) ? found.conditions : [],
293
306
  body: found.body,
294
307
  currentBody: typeof found.currentBody === 'string' ? found.currentBody : found.body,
295
308
  versions,
@@ -328,8 +341,15 @@ function keepHandler(webServer, cwdForSession) {
328
341
  return sendJson(res, 409, { ok: false, reason: '这条记忆的版本不清楚,先刷新再改' });
329
342
  }
330
343
  if (!title.trim()) return sendJson(res, 400, { ok: false, reason: '标题不能为空' });
344
+ /** 面板只让人确认「适用范围」这一件事实,不开放条件编辑;取值由 view 再校验。 */
345
+ const applicability = typeof body.applicability === 'string' ? body.applicability : undefined;
346
+ const conditions = Array.isArray(body.conditions) ? body.conditions : undefined;
331
347
  try {
332
- const result = saveEntry(scope, { id, title, body: text, expectedRevision, scope: 'project', source: 'user_explicit' });
348
+ const result = saveEntry(scope, {
349
+ id, title, body: text, expectedRevision, scope: 'project', origin: 'user_explicit',
350
+ ...(applicability !== undefined ? { applicability } : {}),
351
+ ...(conditions !== undefined ? { conditions } : {}),
352
+ });
333
353
  if (!result || !result.record) {
334
354
  const reason = result && result.reasonCode === 'memory_disabled' ? '本项目记忆已关闭'
335
355
  : result && result.needsResolution ? '改动和另一条太像,先去那条上改'
@@ -448,7 +468,7 @@ function keepHandler(webServer, cwdForSession) {
448
468
  selection: record.selection || null,
449
469
  links: Array.isArray(record.links) ? record.links.slice(0, 24).map((link) => ({ type: link.type || 'related', id: link.id })) : [],
450
470
  revision: Number(record.revision) || 1,
451
- origin: record.origin || 'user_explicit',
471
+ origin: originOut(record.origin),
452
472
  updated: record.updated || '',
453
473
  lastUsed: record.lastUsed || '',
454
474
  bytes: Number(record.bytes) || 0,
@@ -478,10 +498,14 @@ function keepHandler(webServer, cwdForSession) {
478
498
  const currentControl = (() => {
479
499
  try { return controlStatus(scope); } catch { return { enabled: true, epoch: '' }; }
480
500
  })();
501
+ const modeOf = (target) => {
502
+ try { return { effectiveMode: controlStatus(target).mode || hostRecallMode }; } catch { return { effectiveMode: hostRecallMode }; }
503
+ };
504
+ currentControl.effectiveMode = modeOf(scope).effectiveMode;
481
505
  const selectedControl = selectedScope.key === scope.key
482
506
  ? currentControl
483
507
  : (() => {
484
- try { return controlStatus(selectedScope); } catch { return { enabled: true, epoch: '' }; }
508
+ try { return { ...controlStatus(selectedScope), ...modeOf(selectedScope) }; } catch { return { enabled: true, epoch: '', effectiveMode: recallMode() }; }
485
509
  })();
486
510
  const selectedEvolution = selectedScope.key === scope.key
487
511
  ? currentEvolution
@@ -529,8 +553,11 @@ function keepHandler(webServer, cwdForSession) {
529
553
  if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
530
554
  const operationId = typeof body.operationId === 'string' ? body.operationId : '';
531
555
  try {
532
- const result = typeof body.enabled === 'boolean'
533
- ? setMemoryEnabled(scope, body.enabled, { operationId })
556
+ const result = typeof body.enabled === 'boolean' || typeof body.mode === 'string'
557
+ ? setMemoryControl(scope, {
558
+ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}),
559
+ ...(typeof body.mode === 'string' ? { mode: body.mode } : {}),
560
+ }, { operationId })
534
561
  : { ok: true, ...controlStatus(scope) };
535
562
  if (typeof body.enabled === 'boolean' && result && result.ok !== false && body.enabled === false) {
536
563
  const controller = learningAborts.get(scope.key);
@@ -730,13 +757,26 @@ function keepHandler(webServer, cwdForSession) {
730
757
  }
731
758
 
732
759
  /** 给界面的事件引用:只有定位与版次,不带正文,避免把维护字段当内容画出来。 */
760
+ /** 保存失败的统一回执:字段与成功回执同形,渲染只报这一次没写成。 */
761
+ function saveFailure(message, args) {
762
+ return {
763
+ title: String(args && args.title || '').trim(), summary: '', when: '', revision: 0,
764
+ merged: false, changed: false, scope: '', needsResolution: false, needsScope: false,
765
+ archived: '', error: String(message || '').replace(/^memory · /, ''),
766
+ };
767
+ }
768
+
769
+ function originOut(value) {
770
+ return value === 'project_evolution' || value === 'model_drafted' ? value : 'user_explicit';
771
+ }
772
+
733
773
  function refsOf(hits) {
734
774
  return hits.map((item) => ({
735
775
  title: item.title || '',
736
776
  scope: item.scope || 'project',
737
777
  revision: item.revision || 1,
738
778
  status: item.status || 'ok',
739
- origin: item.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
779
+ origin: originOut(item.origin),
740
780
  /** 界面要按类型分开展示(偏好/约定/流程/资料),维护字段不进正文。 */
741
781
  kind: typeof item.kind === 'string' && item.kind ? item.kind : 'note',
742
782
  when: typeof item.when === 'string' ? item.when : '',
@@ -764,7 +804,7 @@ const SAVE_PARAMS = {
764
804
  enum: ['distinct'],
765
805
  description: '明确表示「这确实是另一条」。返回 needsResolution 时,只有确认不是同一件事才带上它再存一次',
766
806
  },
767
- scope: { type: 'string', enum: ['auto', 'project', 'global'], description: '存哪一层:auto 按记忆类别判定(默认);用户明确说「以后所有项目都这样」用 global;明确说只在这个项目用 project' },
807
+ scope: { type: 'string', enum: ['auto', 'project', 'global'], description: '存哪一层:auto 按记忆类别判定(默认);只有用户这一次明确说「以后所有项目都这样」才用 global,模型自己判断通用不是理由;明确说只在这个项目用 project。本机路径/端口和项目事实即使写 global 也会留在项目层,降级原因见 scopeNote;只在本机成立的命令/流程声明 machine 或 runtime 条件后可进全局层' },
768
808
  memoryClass: { type: 'string', enum: ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure'], description: '归属提案:个人偏好 / 项目事实 / 设备事实 / 可搬流程。服务端据此判定存哪层;设备事实必须同时给设备条件' },
769
809
  durability: { type: 'string', enum: ['durable', 'task'], description: '一般认为 durable;只是一次性任务参数用 task,服务端会拒绝落盘' },
770
810
  evidenceRef: { type: 'string', description: '归属判定的依据(哪条消息、哪个文件、哪次观察);用户没说范围时用来判断能不能存' },
@@ -975,7 +1015,8 @@ function statusLabel(status) {
975
1015
  */
976
1016
  export const RECALL_MODES = ['adaptive', 'tool_only', 'off'];
977
1017
  const RECALL_QUERY_CHARS = 400;
978
- const RECALL_DEADLINE_MS = 150;
1018
+ /** 单轮投影的耗时上限:慢机器上宁可这轮不注入,也不把回合拖住。测试可临时放宽。 */
1019
+ let recallDeadlineMs = 150;
979
1020
  const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 带标题取正文,别凭标题猜):';
980
1021
 
981
1022
  /** 卡片只放够判断要不要读的几行;必要前提整条在场,装不下就整卡省略。 */
@@ -1020,6 +1061,16 @@ export function apply(ctx, config) {
1020
1061
 
1021
1062
  /** 升级不覆盖用户的选择:默认 adaptive,别的模式只由明确配置切换。 */
1022
1063
  const recallMode = () => (RECALL_MODES.includes(config?.mode) ? config.mode : 'adaptive');
1064
+ hostRecallMode = recallMode();
1065
+ /** 项目自己的选择优先;没有写过就跟随插件配置,升级不改用户选择。 */
1066
+ const modeFor = (scope) => {
1067
+ try {
1068
+ const mode = controlStatus(scope).mode;
1069
+ return RECALL_MODES.includes(mode) ? mode : recallMode();
1070
+ } catch {
1071
+ return recallMode();
1072
+ }
1073
+ };
1023
1074
  /** 已准备好的快照只挂在 Agent 上,弱引用随手回收,不跨会话串。 */
1024
1075
  const prepared = new WeakMap();
1025
1076
  /** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
@@ -1190,6 +1241,7 @@ export function apply(ctx, config) {
1190
1241
  /** 卡片上限含包装;共享请求账本也要把标题行算进去,不能只算卡片行。 */
1191
1242
  let headerCharged = win ? win.recallHeader === true : false;
1192
1243
  const lines = [];
1244
+ const linesMeta = [];
1193
1245
  const cards = [];
1194
1246
  for (const row of ranked) {
1195
1247
  const usedCards = cards.length + (auto ? (auto.experimentCards || 0) : 0);
@@ -1222,6 +1274,8 @@ export function apply(ctx, config) {
1222
1274
  }
1223
1275
  lines.push(line);
1224
1276
  cards.push({ revision, scope: card.scope, title: card.title });
1277
+ /** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
1278
+ linesMeta.push({ line, validUntil: row.record.validUntil || '' });
1225
1279
  }
1226
1280
  const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1227
1281
  if (auto) {
@@ -1243,6 +1297,8 @@ export function apply(ctx, config) {
1243
1297
  }
1244
1298
  let waiting = near.some((row) => row.status !== 'ok' || row.record.reviewState === 'legacy_unreviewed');
1245
1299
  let nearMatched = near.length;
1300
+ /** 冲突与「条件未确认」是两种原因:前者要人先裁决,后者只差一句任务说明。 */
1301
+ let conflictNear = false;
1246
1302
  if (!cards.length && !ranked.length) {
1247
1303
  /** 整组被条件或冲突挡下的候选没进 records,单独按同一查询算;只计数,不投放。 */
1248
1304
  for (const group of groups) {
@@ -1252,16 +1308,18 @@ export function apply(ctx, config) {
1252
1308
  });
1253
1309
  if (!hits.length) continue;
1254
1310
  waiting = true;
1311
+ if (group.status === 'conflict') conflictNear = true;
1255
1312
  nearMatched += hits.length;
1256
1313
  }
1257
1314
  }
1258
1315
  const reason = cards.length ? 'hit'
1259
1316
  : ranked.length ? 'budget'
1260
- : waiting ? 'needs_context'
1261
- : (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
1317
+ : conflictNear ? 'conflict'
1318
+ : waiting ? 'needs_context'
1319
+ : (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
1262
1320
  const matched = ranked.length || nearMatched;
1263
- if (!cards.length) return { text: '', cards: [], bytes: 0, query, reason, matched };
1264
- return { text: finalText, cards, bytes: Buffer.byteLength(finalText), query, reason, matched };
1321
+ if (!cards.length) return { text: '', cards: [], lines: [], bytes: 0, query, reason, matched };
1322
+ return { text: finalText, cards, lines: linesMeta, bytes: Buffer.byteLength(finalText), query, reason, matched };
1265
1323
  }
1266
1324
 
1267
1325
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1462,7 +1520,16 @@ export function apply(ctx, config) {
1462
1520
  ? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
1463
1521
  : EXPERIMENT_BYTES;
1464
1522
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1465
- for (const record of matched.slice(0, cardCap)) {
1523
+ /** 实验规则也要过本轮相关度:和当前问题没有交集就不占卡片,不能“来自自进化”就天然优先。 */
1524
+ const query = held && held.query ? String(held.query) : '';
1525
+ const relevant = query
1526
+ ? matched.map((record) => ({ record, score: view.matchScore(record, query).score }))
1527
+ /** 条件明确且已满足的规则按绑定注入;无条件的全项目规则必须和本轮问题有交集。 */
1528
+ .filter((row) => row.score > 0 || (Array.isArray(row.record.conditions) && row.record.conditions.length > 0))
1529
+ .sort((a, b) => b.score - a.score)
1530
+ .map((row) => row.record)
1531
+ : matched;
1532
+ for (const record of relevant.slice(0, cardCap)) {
1466
1533
  const body = String(record.body || '');
1467
1534
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
1468
1535
  const conditions = conditionLabel(record.conditions);
@@ -1752,7 +1819,23 @@ export function apply(ctx, config) {
1752
1819
  }
1753
1820
  /** 没有逐条快照时才退回整段到期判断;有快照就只撤到期的那一条。 */
1754
1821
  if (evolutionText && !state.experimentParts && state.validUntil && Date.now() > Date.parse(String(state.validUntil))) evolutionText = '';
1755
- return [evolutionText, state.text].filter(Boolean).join('\n\n');
1822
+ /** 普通卡片也在输出前逐条复核到期:投影到真正发给模型之间可能已经过了 validUntil。 */
1823
+ let normalText = state.text;
1824
+ if (Array.isArray(state.lines)) {
1825
+ const now = Date.now();
1826
+ const live = state.lines.filter((row) => !row.validUntil || now <= Date.parse(String(row.validUntil)));
1827
+ normalText = live.length ? `${RECALL_HEADER}\n${live.map((row) => row.line).join('\n')}` : '';
1828
+ /** 投影时还在、发出时已过期:回执跟着真实输出改,别在面板上说「提醒了 2 条」而正文是空的。 */
1829
+ if (state.scope && live.length !== state.cards.length) {
1830
+ noteRecall(state.scope, state.sessionId, {
1831
+ reason: live.length ? 'hit' : 'expired',
1832
+ matched: state.matched || state.cards.length,
1833
+ cards: live.length,
1834
+ chars: live.length ? view.bytesOf(normalText) : 0,
1835
+ });
1836
+ }
1837
+ }
1838
+ return [evolutionText, normalText].filter(Boolean).join('\n\n');
1756
1839
  } catch {
1757
1840
  return '';
1758
1841
  }
@@ -1771,7 +1854,8 @@ export function apply(ctx, config) {
1771
1854
  revision: value.revision || 0,
1772
1855
  changed: value.changed === true,
1773
1856
  recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1774
- mode: value.needsScope ? 'needs_scope'
1857
+ mode: value.error ? 'failed'
1858
+ : value.needsScope ? 'needs_scope'
1775
1859
  : value.existingTitle ? 'duplicate'
1776
1860
  : value.needsResolution ? 'needs_resolution'
1777
1861
  : value.changed === false ? 'unchanged' : value.merged ? 'updated' : 'created',
@@ -1800,16 +1884,20 @@ export function apply(ctx, config) {
1800
1884
  sameTopic: { type: 'boolean' },
1801
1885
  similarTitles: { type: 'string' },
1802
1886
  similar: { type: 'string' },
1887
+ scopeNote: { type: 'string' },
1803
1888
  supersededTitle: { type: 'string' },
1804
1889
  archived: { type: 'string' },
1805
1890
  pendingConflicts: { type: 'integer' },
1891
+ error: { type: 'string' },
1806
1892
  },
1807
1893
  required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1808
1894
  additionalProperties: false,
1809
1895
  },
1810
1896
  render: (_args, value) => [{
1811
1897
  type: 'text',
1812
- text: value.needsScope
1898
+ text: value.error
1899
+ ? `memory · ${value.error}`
1900
+ : value.needsScope
1813
1901
  ? value.reason === 'memory_disabled'
1814
1902
  ? `memory · ${memoryOffCopy(projectScope)}`
1815
1903
  : value.reason === 'unconfirmed_project'
@@ -1821,7 +1909,7 @@ export function apply(ctx, config) {
1821
1909
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它:用同一个标题${value.crossScope === 'global' ? '、并带 scope="global"' : ''}再存一次;确实是另一条,加 resolution="distinct"`
1822
1910
  : value.changed === false
1823
1911
  ? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
1824
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededTitle ? ` · 已取代「${value.supersededTitle}」` : ''}`,
1912
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededTitle ? ` · 已取代「${value.supersededTitle}」` : ''}${value.scopeNote ? ` · ${value.scopeNote}` : ''}`,
1825
1913
  }],
1826
1914
  },
1827
1915
  execute: async (args, execution) => {
@@ -1831,7 +1919,6 @@ export function apply(ctx, config) {
1831
1919
  return {
1832
1920
  title: String(args.title || ''), summary: '', when: '', revision: 0,
1833
1921
  merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1834
- recoveryPending: controlStatus(projectScope).recoveryPending,
1835
1922
  needsResolution: false, similarTitles: '', similar: '', archived: '',
1836
1923
  };
1837
1924
  }
@@ -1839,17 +1926,18 @@ export function apply(ctx, config) {
1839
1926
  const supersedesId = supersedes && !/^mem_[a-z0-9]+$/.test(supersedes)
1840
1927
  ? (() => { try { return refIdOf(projectScope, { title: supersedes }, '记忆'); } catch { return ''; } })()
1841
1928
  : supersedes;
1842
- const draft = { ...args, writeMode: args.mode || 'patch' };
1929
+ /** 工具写入的正文由模型写,来源标成模型整理,不能冒充用户原话。 */
1930
+ const draft = { ...args, writeMode: args.mode || 'patch', origin: 'model_drafted' };
1843
1931
  if (supersedes) { if (supersedesId) draft.supersedes = supersedesId; else delete draft.supersedes; }
1844
1932
  /** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
1845
1933
  const updateTitle = String(args.updateTitle || '').trim();
1846
1934
  if (updateTitle) {
1847
1935
  const candidates = args.scope ? [] : titleCandidates(projectScope, updateTitle);
1848
1936
  if (candidates.length > 1) {
1849
- return { error: `memory · 「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"` };
1937
+ return saveFailure(`「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"`, args);
1850
1938
  }
1851
1939
  const updateId = (() => { try { return refIdOf(projectScope, { title: updateTitle, scope: args.scope }, '记忆'); } catch { return ''; } })();
1852
- if (!updateId) return { error: `memory · 没有「${updateTitle}」这条记忆;要新增就不用 updateTitle` };
1940
+ if (!updateId) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
1853
1941
  draft.id = updateId;
1854
1942
  }
1855
1943
  /** 关联目标同样按标题解析成内部 id 再落盘。 */
@@ -1857,9 +1945,9 @@ export function apply(ctx, config) {
1857
1945
  const resolved = [];
1858
1946
  for (const link of args.links) {
1859
1947
  const title = typeof link?.title === 'string' ? link.title.trim() : '';
1860
- if (!title) return { error: 'memory · 关联要写成 {type, title}:给被关联那条记忆的标题' };
1948
+ if (!title) return saveFailure('关联要写成 {type, title}:给被关联那条记忆的标题', args);
1861
1949
  const targetId = (() => { try { return refIdOf(projectScope, { title }, '记忆'); } catch { return ''; } })();
1862
- if (!targetId) return { error: `memory · 没有「${title}」这条记忆,关联没写成` };
1950
+ if (!targetId) return saveFailure(`没有「${title}」这条记忆,关联没写成`, args);
1863
1951
  resolved.push({ type: link.type || 'related', id: targetId });
1864
1952
  }
1865
1953
  draft.links = resolved;
@@ -1902,6 +1990,13 @@ export function apply(ctx, config) {
1902
1990
  };
1903
1991
  }
1904
1992
  const scope = saved.chosenScope === 'global' || (args.scope === 'global' && !saved.chosenScope) ? globalScope() : projectScope;
1993
+ /** 显式要了全局却被硬冲突挡回项目层时说清原因:否则模型只能看到 scope=project,无从纠。 */
1994
+ const scopeNote = (() => {
1995
+ if (args.scope !== 'global' || scope.key === 'global') return '';
1996
+ if (saved.reasonCode === 'machine_bound_scope') return '按本机痕迹留在项目层:正文或依据里写了本机路径/端口,改成别的项目仍成立的通用规则再存全局;只在本机适用的流程可补 machine 条件后存全局';
1997
+ if (saved.reasonCode === 'model_global_project_fact') return '按项目事实留在项目层:换成别的项目就不成立;确实是通用流程就改 memoryClass';
1998
+ return '';
1999
+ })();
1905
2000
  if (saved.needsResolution) {
1906
2001
  return {
1907
2002
  title: saved.draft.title,
@@ -1919,6 +2014,7 @@ export function apply(ctx, config) {
1919
2014
  similarTitles: saved.similarList.map((item) => item.title).join('、'),
1920
2015
  similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
1921
2016
  archived: '',
2017
+ ...(scopeNote ? { scopeNote } : {}),
1922
2018
  pendingConflicts: Number(saved.pendingConflicts) || 0,
1923
2019
  };
1924
2020
  }
@@ -1938,6 +2034,7 @@ export function apply(ctx, config) {
1938
2034
  needsScope: false,
1939
2035
  needsResolution: false,
1940
2036
  similarTitles: saved.similarList.map((item) => item.title).join('、'),
2037
+ ...(scopeNote ? { scopeNote } : {}),
1941
2038
  supersededTitle: supersedes && supersedesId ? supersedes : '',
1942
2039
  archived: (saved.archived || []).map((item) => item.title).join('、'),
1943
2040
  pendingConflicts: Number(saved.pendingConflicts) || 0,
@@ -2040,7 +2137,7 @@ export function apply(ctx, config) {
2040
2137
  }],
2041
2138
  },
2042
2139
  execute: async (args, execution) => {
2043
- if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
2140
+ if (!memoryEnabledFor(execution) || modeFor(scopeOf(cwdOf(execution))) === 'off') return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
2044
2141
  const agent = execution?.agent;
2045
2142
  const win = windowOf(agent);
2046
2143
  if (win && !affordableTool(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
@@ -2143,7 +2240,7 @@ export function apply(ctx, config) {
2143
2240
  revision: { type: 'integer' },
2144
2241
  scope: { type: 'string' },
2145
2242
  scopeName: { type: 'string' },
2146
- origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
2243
+ origin: { type: 'string', enum: ['user_explicit', 'model_drafted', 'project_evolution'] },
2147
2244
  kind: { type: 'string' },
2148
2245
  status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
2149
2246
  body: { type: 'string' },
@@ -2180,13 +2277,13 @@ export function apply(ctx, config) {
2180
2277
  title: value.title || '',
2181
2278
  scope: value.scope || '',
2182
2279
  revision: value.revision || 0,
2183
- origin: value.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
2280
+ origin: originOut(value.origin),
2184
2281
  recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
2185
2282
  status: value.status || 'ok',
2186
2283
  }),
2187
2284
  },
2188
2285
  execute: async (args, execution) => {
2189
- if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { scope: '', status: 'memory_disabled' };
2286
+ if (!memoryEnabledFor(execution) || modeFor(scopeOf(cwdOf(execution))) === 'off') return { scope: '', status: 'memory_disabled' };
2190
2287
  const resolved = resolveRequestContext(execution, args.contextId);
2191
2288
  if (!resolved.ok) return { scope: '', status: resolved.status || 'needs_context' };
2192
2289
  const scope = resolved.scope;
@@ -2222,7 +2319,7 @@ export function apply(ctx, config) {
2222
2319
  ? '- 有一条已失效'
2223
2320
  : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」`));
2224
2321
  const text = rows.join('\n') || '(无)';
2225
- const value = { scope: found.scope, scopeName, status: found.status, links: text };
2322
+ const value = { title: found.title || '', scope: found.scope, scopeName, status: found.status, links: text };
2226
2323
  if (win) {
2227
2324
  const bytes = jsonBytes(value);
2228
2325
  if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
@@ -2258,6 +2355,8 @@ export function apply(ctx, config) {
2258
2355
  scopeName,
2259
2356
  status: record.status,
2260
2357
  kind: record.kind || 'note',
2358
+ /** 来源随正文回执一起给:模型整理稿要能标出来,但不会因此变成全局。 */
2359
+ origin: originOut(record.origin),
2261
2360
  body,
2262
2361
  };
2263
2362
  if (win) {
@@ -2488,6 +2587,7 @@ export function apply(ctx, config) {
2488
2587
  if (snapshot || experimentText) {
2489
2588
  previous.text = snapshot ? snapshot.text : '';
2490
2589
  previous.cards = snapshot ? snapshot.cards : [];
2590
+ previous.lines = snapshot ? snapshot.lines : [];
2491
2591
  previous.experimentText = experimentText;
2492
2592
  previous.experimentParts = win && win.experiment ? (win.experiment.experimentParts || null) : null;
2493
2593
  previous.validUntil = win && win.experiment ? (win.experiment.validUntil || '') : '';
@@ -2510,7 +2610,10 @@ export function apply(ctx, config) {
2510
2610
  autoBudget: win.autoBudget ? JSON.parse(JSON.stringify(win.autoBudget)) : null,
2511
2611
  } : null;
2512
2612
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2513
- const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2613
+ const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2614
+ /** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
2615
+ const carried = sameSession && previous?.query && view.isContinuationQuery(said) ? previous.query : '';
2616
+ const query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
2514
2617
  if (win && query) win.query = query;
2515
2618
 
2516
2619
  if (!query) {
@@ -2533,10 +2636,10 @@ export function apply(ctx, config) {
2533
2636
  * 工具模式也必须先有本请求上下文:memory_search/read/links 靠同一个 contextId 判项目和任务,
2534
2637
  * 不能等到自动召回分支才建,否则工具模式永远拿不到可核对的任务。
2535
2638
  */
2536
- const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
2639
+ const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: modeFor(scope) === 'adaptive' });
2537
2640
  if (win && runtime) win.runtime = runtime.held;
2538
2641
  rememberRuntime(scope, sessionId, runtime && runtime.held);
2539
- if (recallMode() !== 'adaptive') {
2642
+ if (modeFor(scope) !== 'adaptive') {
2540
2643
  noteRecall(scope, sessionId, { reason: 'tools_only' });
2541
2644
  clearPrepared(agent);
2542
2645
  return decision;
@@ -2553,7 +2656,30 @@ export function apply(ctx, config) {
2553
2656
  const remembered = runtime && runtime.enabled
2554
2657
  ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
2555
2658
  : null;
2556
- const snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2659
+ let snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2660
+ /**
2661
+ * 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
2662
+ * 第一次检索一张卡都没有时,用最近的实词/工具名补一次有界查询,预算仍是同一份。
2663
+ */
2664
+ if (!snapshot.cards.length && !carried) {
2665
+ const humanTail = (() => {
2666
+ const messages = Array.isArray(decision.messages) ? decision.messages : [];
2667
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2668
+ if (messages[i]?.source?.kind === 'user') return messages.slice(0, i);
2669
+ }
2670
+ return messages;
2671
+ })();
2672
+ const trail = view.actionTerms(humanTail, { limit: 12 }).slice(0, 240);
2673
+ if (trail) {
2674
+ const actionQuery = `${trail} ${query}`.trim().slice(0, RECALL_QUERY_CHARS);
2675
+ const retry = projectRecall(scope, actionQuery, recalledContext, win, runtime ? runtime.held : null);
2676
+ /** 补查是为了给出提醒:补不到就保留第一次的「为什么没提醒」,不拿空结果盖掉。 */
2677
+ if (retry.cards.length) {
2678
+ snapshot = retry;
2679
+ if (win) win.query = actionQuery;
2680
+ }
2681
+ }
2682
+ }
2557
2683
  noteRecall(scope, sessionId, {
2558
2684
  reason: snapshot.reason, matched: snapshot.matched, cards: snapshot.cards.length, chars: snapshot.bytes,
2559
2685
  });
@@ -2572,16 +2698,18 @@ export function apply(ctx, config) {
2572
2698
  win.experiment = null;
2573
2699
  win.contexts.clear();
2574
2700
  };
2575
- if (generation !== generations.get(agent)) { rollbackBudget(); return decision; }
2701
+ if (generation !== generations.get(agent)) { rollbackBudget(); if (snapshot.cards.length) clearRecall(scope, sessionId); return decision; }
2576
2702
  /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
2577
2703
  if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
2578
2704
  rollbackBudget();
2705
+ if (snapshot.cards.length) clearRecall(scope, sessionId);
2579
2706
  clearPrepared(agent);
2580
2707
  return decision;
2581
2708
  }
2582
2709
  /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
2583
- if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
2710
+ if (signal?.aborted || Date.now() - startedAt > recallDeadlineMs) {
2584
2711
  rollbackBudget();
2712
+ if (snapshot.cards.length) clearRecall(scope, sessionId);
2585
2713
  clearPrepared(agent);
2586
2714
  return decision;
2587
2715
  }
@@ -2599,6 +2727,9 @@ export function apply(ctx, config) {
2599
2727
  experimentParts: experiment && experiment.held ? (experiment.held.experimentParts || null) : null,
2600
2728
  validUntil: experiment && experiment.held ? (experiment.held.validUntil || '') : '',
2601
2729
  cards: snapshot ? snapshot.cards : [],
2730
+ lines: snapshot ? snapshot.lines : [],
2731
+ matched: snapshot ? snapshot.matched : 0,
2732
+ scope,
2602
2733
  query,
2603
2734
  sessionId,
2604
2735
  turn,
@@ -2614,7 +2745,7 @@ export function apply(ctx, config) {
2614
2745
  if (signal) signalAgents.set(signal, agent);
2615
2746
  const key = agentKey(agent);
2616
2747
  if (key && signal && typeof signal.addEventListener === 'function') {
2617
- const onAbort = () => clearPrepared(agent);
2748
+ const onAbort = () => { clearPrepared(agent); clearRecall(scope, sessionId); };
2618
2749
  signal.addEventListener('abort', onAbort, { once: true });
2619
2750
  abortCleanups.set(key, () => signal.removeEventListener('abort', onAbort));
2620
2751
  }
@@ -2914,6 +3045,8 @@ export function apply(ctx, config) {
2914
3045
  mode: recallMode,
2915
3046
  preparedOf: (agent) => prepared.get(agent) || null,
2916
3047
  windowOf: (agent) => windowOf(agent),
3048
+ /** 测试专用:CI 冷启动偶尔超过 150ms,逻辑测试不该被耗时上限判失败。 */
3049
+ setDeadlineMs: (ms) => { if (Number.isFinite(ms) && ms > 0) recallDeadlineMs = ms; },
2917
3050
  };
2918
3051
  return () => {
2919
3052
  delete globalThis.__liteMemoryRecall;
package/learning.js CHANGED
@@ -44,7 +44,8 @@ const UNCLEAR = /不太对|再想想|感觉不对|说不上|有点怪|不对劲/
44
44
  * 点名项目范围才算全项目授权;「以后都」不等于「所有项目」。
45
45
  * 「所有口播」「一律」这种覆盖全部对象的说法也算,但只限当前项目层。
46
46
  */
47
- const PROJECT_WIDE = /(这个项目|本项目|所有项目|全项目|整个项目|全库|每个项目|所有(?!项目)|一律)/;
47
+ const PROJECT_WIDE = /(这个项目|本项目|所有项目|全项目|整个项目|全库|每个项目)/;
48
+ /** 「所有口播」「一律」限定的是对象,不是整个项目:没有可信范围时只算待确认。 */
48
49
 
49
50
  const has = (pattern, text) => pattern.test(text);
50
51
 
@@ -380,7 +381,7 @@ export function pendingChangeFrom(feedback = {}) {
380
381
  ? (expected.applicability === 'all_in_scope'
381
382
  ? 'all_in_scope'
382
383
  : expectedConditions.length ? 'conditional' : 'unresolved')
383
- : /(所有|全都|一律|任何|默认|整个项目|这个项目|本项目)/.test(currentText)
384
+ : /(这个项目|本项目|所有项目|全项目|整个项目|全库|每个项目)/.test(currentText)
384
385
  ? 'all_in_scope'
385
386
  : taskKind ? 'conditional' : 'unresolved';
386
387
  const conditions = expected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.19",
3
+ "version": "0.18.26",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/state.js CHANGED
@@ -106,10 +106,12 @@ export function evolutionOf(state) {
106
106
  export function controlOf(state) {
107
107
  const raw = state && state.control;
108
108
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
109
- return { enabled: true, epoch: '', changedAt: '', recoveryPending: false, compatible: true };
109
+ return { enabled: true, mode: '', epoch: '', changedAt: '', recoveryPending: false, compatible: true };
110
110
  }
111
111
  return {
112
112
  enabled: raw.enabled !== false && raw.recoveryPending !== true,
113
+ /** 空字符串表示跟随插件配置;只有明确写过的项目才覆盖。 */
114
+ mode: raw.mode === 'adaptive' || raw.mode === 'tool_only' || raw.mode === 'off' ? raw.mode : '',
113
115
  epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
114
116
  changedAt: typeof raw.changedAt === 'string' ? raw.changedAt : '',
115
117
  recoveryPending: raw.recoveryPending === true,
@@ -122,12 +124,15 @@ export function controlOf(state) {
122
124
  * 只清运行层(在途整理、临时锚点、旧召回快照)。重新打开也换代次,关闭前排队的任务不能补交。
123
125
  * 自进化跟随总开关一起停:重新打开记忆后,自进化仍是关闭的,等用户单独开启。
124
126
  */
125
- export function applyControl(draft, enabled) {
127
+ export function applyControl(draft, enabled, mode) {
126
128
  const now = new Date().toISOString();
127
129
  const current = controlOf(draft);
128
- const next = Boolean(enabled);
129
- if (current.enabled === next) return { changed: false, enabled: next };
130
- const config = { enabled: next, epoch: crypto.randomUUID(), changedAt: now };
130
+ const next = typeof enabled === 'boolean' ? enabled : current.enabled;
131
+ const nextMode = mode === 'adaptive' || mode === 'tool_only' || mode === 'off'
132
+ ? mode
133
+ : current.mode;
134
+ if (current.enabled === next && current.mode === nextMode) return { changed: false, enabled: next, mode: nextMode };
135
+ const config = { enabled: next, mode: nextMode, epoch: crypto.randomUUID(), changedAt: now };
131
136
  draft.control = config;
132
137
  const evolution = draft.experimental && draft.experimental.projectEvolution;
133
138
  if (evolution && typeof evolution === 'object' && !Array.isArray(evolution)) {
@@ -141,7 +146,7 @@ export function applyControl(draft, enabled) {
141
146
  /** 额度只增不减:次数与回执留着,最近观察清掉。 */
142
147
  evolution.learning = { ...ledger, recent: [] };
143
148
  }
144
- return { changed: true, enabled: next, epoch: config.epoch };
149
+ return { changed: true, enabled: next, mode: nextMode, epoch: config.epoch };
145
150
  }
146
151
 
147
152
  /**