dsh-lite-memory 0.16.4 → 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/index.js CHANGED
@@ -226,6 +226,13 @@ function keepHandler(webServer, cwdForSession) {
226
226
  return sendJson(res, 400, { ok: false, reason: '缺少来源' });
227
227
  }
228
228
  if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
229
+ /** 面板要分开「当前规范」和旧版本;历史只作可折叠对照,不拼进正文。 */
230
+ const versions = Array.isArray(found.versions) ? found.versions
231
+ : Array.isArray(found.history) ? found.history : [];
232
+ const learning = found.learning && typeof found.learning === 'object' ? found.learning : {};
233
+ const enums = new Set(['applied', 'unchanged', 'superseded', 'failed']);
234
+ const processingStatus = enums.has(found.processingStatus) ? found.processingStatus
235
+ : enums.has(learning.lastStatus) ? learning.lastStatus : '';
229
236
  return sendJson(res, 200, {
230
237
  ok: true,
231
238
  id: found.id,
@@ -236,6 +243,13 @@ function keepHandler(webServer, cwdForSession) {
236
243
  scopeLabel: found.scopeLabel || '',
237
244
  revision: found.revision,
238
245
  body: found.body,
246
+ currentBody: typeof found.currentBody === 'string' ? found.currentBody : found.body,
247
+ versions,
248
+ sourceExcerpt: found.sourceExcerpt || learning.sourceExcerpt || '',
249
+ sourceRefs: Array.isArray(found.sourceRefs) ? found.sourceRefs : (Array.isArray(learning.sourceRefs) ? learning.sourceRefs : []),
250
+ evidence: found.evidence || (found.evidenceRef ? [found.evidenceRef] : []),
251
+ evidenceRef: found.evidenceRef || '',
252
+ processingStatus,
239
253
  });
240
254
  }
241
255
 
@@ -627,6 +641,11 @@ function refsOf(hits) {
627
641
  revision: item.revision || 1,
628
642
  status: item.status || 'ok',
629
643
  origin: item.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
644
+ /** 界面要按类型分开展示(偏好/约定/流程/资料),维护字段不进正文。 */
645
+ kind: typeof item.kind === 'string' && item.kind ? item.kind : 'note',
646
+ when: typeof item.when === 'string' ? item.when : '',
647
+ /** 界面要能说「N 天前更新」,只给时间不给正文。 */
648
+ updated: typeof item.updated === 'string' ? item.updated : '',
630
649
  }));
631
650
  }
632
651
 
@@ -640,6 +659,10 @@ const SAVE_PARAMS = {
640
659
  when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
641
660
  kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
642
661
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
662
+ supersedes: {
663
+ type: 'string',
664
+ description: '要取代的记忆 id:新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
665
+ },
643
666
  resolution: {
644
667
  type: 'string',
645
668
  enum: ['distinct'],
@@ -778,6 +801,29 @@ function withTag(value, text) {
778
801
  return tag ? `${tag} ${text}` : text;
779
802
  }
780
803
 
804
+ /** 候选要能自己解释「什么时候的、为什么像」,模型才好判断是更新还是另一条。 */
805
+ function humanAge(iso) {
806
+ const at = Date.parse(String(iso || ''));
807
+ if (!Number.isFinite(at)) return '';
808
+ const days = Math.floor((Date.now() - at) / 86400000);
809
+ if (days <= 0) return '今天';
810
+ if (days === 1) return '昨天';
811
+ if (days < 30) return `${days} 天前`;
812
+ if (days < 365) return `${Math.floor(days / 30)} 个月前`;
813
+ return `${Math.floor(days / 365)} 年前`;
814
+ }
815
+
816
+ function describeCandidate(item, options = {}) {
817
+ const age = humanAge(item.updated);
818
+ const why = options.sameTopic ? '' : item.reason === 'similar_summary' ? '摘要相近' : '标题相近';
819
+ const parts = [`${item.id}「${item.title}」`];
820
+ if (age) parts.push(`${age}更新`);
821
+ if (item.when) parts.push(item.when);
822
+ if (why) parts.push(why);
823
+ if (item.summary) parts.push(item.summary);
824
+ return parts.join(' · ');
825
+ }
826
+
781
827
  function statusLabel(status) {
782
828
  if (status === 'archived') return ' · 已归档';
783
829
  if (status === 'needs_context') return ' · 适用范围待确认';
@@ -872,6 +918,14 @@ export function apply(ctx, config) {
872
918
 
873
919
  const windowOf = (agent) => windows.get(agentKey(agent)) || null;
874
920
 
921
+ /** 自动注入账本:普通段与实验段共用 3 条 / 1200 字节,量的是拼接后的完整文本。 */
922
+ const emptyAutoBudget = () => ({
923
+ normalText: '', experimentText: '',
924
+ normalCards: 0, experimentCards: 0,
925
+ cards: 0, cardBytes: 0, experimentBytes: 0, totalBytes: 0,
926
+ });
927
+
928
+
875
929
  /** 预算按规范化 JSON 字节算:转义、中文、换行都算进模型真正收到的内容。 */
876
930
  const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value));
877
931
 
@@ -882,6 +936,16 @@ export function apply(ctx, config) {
882
936
  return win.bytes + bytes <= view.REQUEST_BYTES;
883
937
  }
884
938
 
939
+ /** 续写轮没有新消息:沿用上一次能核对的请求上下文。 */
940
+ const lastRuntime = new Map();
941
+ const rememberRuntime = (scope, sessionId, held) => {
942
+ if (!held || !sessionId) return;
943
+ const key = `${scope.key}\u0000${sessionId}`;
944
+ lastRuntime.delete(key);
945
+ lastRuntime.set(key, held);
946
+ if (lastRuntime.size > 20) lastRuntime.delete(lastRuntime.keys().next().value);
947
+ };
948
+
885
949
  function openWindow(agent, { sessionId, turn, messageIds }) {
886
950
  const key = agentKey(agent);
887
951
  if (!key) return null;
@@ -892,7 +956,7 @@ export function apply(ctx, config) {
892
956
  signature, sessionId, turn, messageIds,
893
957
  bytes: 0, bodies: 0, reads: 0,
894
958
  provided: new Map(), readBodies: new Map(), at: Date.now(),
895
- experiment: null, contexts: new Map(),
959
+ experiment: null, contexts: new Map(), runtime: null, autoBudget: null,
896
960
  };
897
961
  windows.set(key, next);
898
962
  return next;
@@ -917,6 +981,11 @@ export function apply(ctx, config) {
917
981
  }
918
982
 
919
983
  /** 两层都算:任何一层换库、换代或清空,之前准备的卡片都要失效。 */
984
+ /**
985
+ * 同一请求的召回快照只要内容没变就保持原样:命中次数、使用时间这类使用记录
986
+ * 不参与指纹,否则模型多读一次就会触发重投影。真正改变应看条目内容版本、
987
+ * 启停/控制代号与自进化代号。
988
+ */
920
989
  function fingerprintOf(scope) {
921
990
  try {
922
991
  return [scope, globalScopeView()].map((source) => {
@@ -924,7 +993,11 @@ export function apply(ctx, config) {
924
993
  const meta = index.__meta || {};
925
994
  const evolutionEpoch = index.__evolution && index.__evolution.epoch ? index.__evolution.epoch : '';
926
995
  const controlEpoch = index.__control && index.__control.epoch ? index.__control.epoch : '';
927
- return `${index.scope}:${meta.storeId || ''}:${index.generation}:${index.epoch}:${evolutionEpoch}:${controlEpoch}`;
996
+ const content = (index.entries || [])
997
+ .map((entry) => `${entry.id}:${entry.revision || 1}:${entry.status || 'active'}:${entry.updated || ''}:${entry.topicKey || ''}`)
998
+ .sort()
999
+ .join(',');
1000
+ return `${index.scope}:${meta.storeId || ''}:${evolutionEpoch}:${controlEpoch}:${content}`;
928
1001
  }).join('|');
929
1002
  } catch {
930
1003
  return '';
@@ -934,7 +1007,8 @@ export function apply(ctx, config) {
934
1007
  const globalScopeView = () => globalScope();
935
1008
 
936
1009
  /** 纯内存:读两层当前投影,先判适用再排序,整卡装预算。 */
937
- function projectRecall(scope, query, context, win) {
1010
+ function projectRecall(scope, query, context, win, runtime) {
1011
+ context = view.contextWithTask(context, runtime && runtime.task);
938
1012
  const layers = { project: [], global: [] };
939
1013
  const seen = new Set();
940
1014
  for (const source of [scope, globalScope()]) {
@@ -958,38 +1032,61 @@ export function apply(ctx, config) {
958
1032
  records.push(group.record);
959
1033
  layerOf.set(group.record.id, group.scope === 'global' ? 'global' : 'project');
960
1034
  }
961
- const ranked = view.rank(records, query, { limit: 8, requireApplicable: true, context });
962
- const headerBytes = Buffer.byteLength(RECALL_HEADER);
963
- let used = headerBytes;
1035
+ const ranked = view.rank(records, query, {
1036
+ limit: 8, requireApplicable: true, context,
1037
+ runtime: runtime || (win && win.runtime) || null,
1038
+ task: runtime && runtime.task ? runtime.task : null,
1039
+ });
1040
+ /** 普通记忆与实验规则共用一份自动注入预算:总条数和最终完整文本字节一起算。 */
1041
+ const auto = win && win.autoBudget ? win.autoBudget : null;
964
1042
  /** 卡片上限含包装;共享请求账本也要把标题行算进去,不能只算卡片行。 */
965
1043
  let headerCharged = win ? win.recallHeader === true : false;
966
1044
  const lines = [];
967
1045
  const cards = [];
968
1046
  for (const row of ranked) {
969
- if (cards.length >= view.CARD.max) break;
1047
+ const usedCards = cards.length + (auto ? (auto.experimentCards || 0) : 0);
1048
+ if (usedCards >= view.CARD.max) break;
970
1049
  const card = view.cardOf(row.record, layerOf.get(row.record.id));
971
1050
  if (card.scope === 'project') card.scopeName = projectNameOf(scope);
972
1051
  const line = renderRecallCard(card);
973
- const size = Buffer.byteLength(`\n${line}`);
974
- if (used + size > view.CARD.bytes) continue;
1052
+ const nextText = `${RECALL_HEADER}\n${[...lines, line].join('\n')}`;
1053
+ if (auto) {
1054
+ /** 实验段可能先渲染:组装成最终两段再量,超了就跳过这张,不把标题留在模型侧。 */
1055
+ const admitted = admitAuto(win, {
1056
+ normal: nextText,
1057
+ experiment: auto.experimentText || '',
1058
+ normalCards: cards.length + 1,
1059
+ experimentCards: auto.experimentCards || 0,
1060
+ });
1061
+ if (!admitted.ok) continue;
1062
+ } else {
1063
+ const size = Buffer.byteLength(nextText);
1064
+ if (size > view.CARD.bytes) continue;
1065
+ const cost = Buffer.byteLength(`\n${line}`) + (headerCharged ? 0 : Buffer.byteLength(RECALL_HEADER));
1066
+ if (win && !affordable(win, cost)) continue;
1067
+ if (win) win.bytes += cost;
1068
+ }
975
1069
  const revision = card.revision || 1;
976
- const already = win ? win.provided.get(card.id) === revision : false;
977
- if (win && !already) {
978
- const cost = size + (headerCharged ? 0 : headerBytes);
979
- if (!affordable(win, cost)) continue;
980
- win.bytes += cost;
981
- win.provided.set(card.id, revision);
982
- if (!headerCharged) {
983
- headerCharged = true;
984
- win.recallHeader = true;
985
- }
1070
+ if (win) win.provided.set(card.id, revision);
1071
+ if (win && !headerCharged) {
1072
+ headerCharged = true;
1073
+ win.recallHeader = true;
986
1074
  }
987
- used += size;
988
1075
  lines.push(line);
989
1076
  cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
990
1077
  }
1078
+ const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1079
+ if (auto) {
1080
+ /** 没有任何普通卡片时也要把已有实验段记账成当前状态。 */
1081
+ admitAuto(win, {
1082
+ normal: lines.length ? finalText : (auto.normalText || ''),
1083
+ experiment: auto.experimentText || '',
1084
+ normalCards: cards.length,
1085
+ experimentCards: auto.experimentCards || 0,
1086
+ });
1087
+ }
991
1088
  if (!cards.length) return null;
992
- return { text: `${RECALL_HEADER}\n${lines.join('\n')}`, cards, bytes: used, query };
1089
+ return { text: finalText, cards, bytes: Buffer.byteLength(finalText), query };
993
1090
  }
994
1091
 
995
1092
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1037,9 +1134,62 @@ export function apply(ctx, config) {
1037
1134
  .join('、');
1038
1135
  }
1039
1136
 
1040
- /** 实验内容单独记账:同一请求里再次渲染只补差额,不重复占满预算。 */
1041
- function chargeExperiment(win, text) {
1137
+ /**
1138
+ * 自动注入只有一份预算:普通卡片与实验规则合计最多 3 条 / 1200 字节。
1139
+ * 这里按 provider 最终会看到的完整文本量字节(含标题行、ID、换行和两段之间的空行),
1140
+ * 先组装再测量,不能只量卡片正文再拼。
1141
+ */
1142
+ function autoTextOf(win, normal, experiment) {
1143
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1144
+ const n = normal === undefined ? String(auto?.normalText || '') : String(normal || '');
1145
+ const e = experiment === undefined ? String(auto?.experimentText || '') : String(experiment || '');
1146
+ return { normal: n, experiment: e, text: [e, n].filter(Boolean).join('\n\n') };
1147
+ }
1148
+
1149
+ function autoBytesOf(win, normal, experiment) {
1150
+ return Buffer.byteLength(autoTextOf(win, normal, experiment).text);
1151
+ }
1152
+
1153
+ /**
1154
+ * 原子准入:量的是最终完整文本和总条数;通过才写入预算账本。
1155
+ * 任何一段超限都整段回退,不截半句前提、不把标题留在模型侧。
1156
+ */
1157
+ function admitAuto(win, { normal, experiment, normalCards, experimentCards } = {}) {
1158
+ if (!win || !win.autoBudget) return { ok: false, bytes: 0 };
1159
+ const auto = win.autoBudget;
1160
+ const parts = autoTextOf(win, normal, experiment);
1161
+ const cards = (normalCards === undefined ? (auto.normalCards || 0) : normalCards)
1162
+ + (experimentCards === undefined ? (auto.experimentCards || 0) : experimentCards);
1163
+ const bytes = Buffer.byteLength(parts.text);
1164
+ if (cards > view.CARD.max || bytes > view.CARD.bytes) return { ok: false, bytes, cards };
1165
+ const previousTotal = Number(auto.totalBytes || 0);
1166
+ if (!affordable(win, bytes - previousTotal)) return { ok: false, bytes, cards };
1167
+ auto.normalText = parts.normal;
1168
+ auto.experimentText = parts.experiment;
1169
+ auto.normalCards = normalCards === undefined ? (auto.normalCards || 0) : normalCards;
1170
+ auto.experimentCards = experimentCards === undefined ? (auto.experimentCards || 0) : experimentCards;
1171
+ auto.cards = cards;
1172
+ auto.cardBytes = Buffer.byteLength(parts.normal);
1173
+ auto.experimentBytes = Buffer.byteLength(parts.experiment);
1174
+ auto.totalBytes = bytes;
1175
+ win.bytes += bytes - previousTotal;
1176
+ return { ok: true, bytes, cards };
1177
+ }
1178
+
1179
+ /** 实验内容单独记账:同一请求里再次渲染替换旧段,只补差额。 */
1180
+ function chargeExperiment(win, text, cards = 1) {
1042
1181
  if (!text) return '';
1182
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1183
+ if (auto) {
1184
+ const admitted = admitAuto(win, {
1185
+ normal: auto.normalText || '',
1186
+ experiment: text,
1187
+ normalCards: auto.normalCards || 0,
1188
+ experimentCards: cards,
1189
+ });
1190
+ if (!admitted.ok) return '';
1191
+ return text;
1192
+ }
1043
1193
  const bytes = Buffer.byteLength(`\n\n${text}`);
1044
1194
  const previous = win ? (win.experimentBytes || 0) : 0;
1045
1195
  if (bytes > EXPERIMENT_BYTES) return '';
@@ -1128,8 +1278,16 @@ export function apply(ctx, config) {
1128
1278
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
1129
1279
  }
1130
1280
  const parts = { header: EXPERIMENT_RULES_HEADER, items: [], tail: held && held.contextId ? `\ncontextId=${held.contextId}` : '' };
1281
+ /** 与普通记忆共用 3 张卡片 / 1200 字节:普通卡片先占掉的位子和字节,实验规则不能再拿。 */
1282
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1283
+ const cardCap = auto
1284
+ ? Math.max(0, view.CARD.max - (auto.normalCards || 0) - (auto.experimentCards || 0))
1285
+ : view.CARD.max;
1286
+ const autoCap = auto
1287
+ ? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
1288
+ : EXPERIMENT_BYTES;
1131
1289
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1132
- for (const record of matched.slice(0, 3)) {
1290
+ for (const record of matched.slice(0, cardCap)) {
1133
1291
  const body = String(record.body || '');
1134
1292
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
1135
1293
  const conditions = conditionLabel(record.conditions);
@@ -1141,7 +1299,7 @@ export function apply(ctx, config) {
1141
1299
  ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1142
1300
  : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
1143
1301
  const size = Buffer.byteLength(`\n${line}`);
1144
- if (used + size > EXPERIMENT_BYTES) break;
1302
+ if (used + size > autoCap) break;
1145
1303
  used += size;
1146
1304
  const trialLearning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1147
1305
  const trialUntil = trialLearning.state === 'trial' && Number.isFinite(Date.parse(String(trialLearning.trialUntil || '')))
@@ -1154,71 +1312,138 @@ export function apply(ctx, config) {
1154
1312
  return chargeExperiment(win, '有些实验规则的适用条件还没确认,先按用户这次说的做,别把它们当本轮依据。');
1155
1313
  }
1156
1314
  if (!parts.items.length) return '';
1315
+ /** 先按最终整段文本过预算,再登记可发布内容;没通过就一条都不留。 */
1316
+ const admitted = chargeExperiment(win, experimentTextFrom(parts), parts.items.length);
1317
+ if (!admitted) {
1318
+ if (held) { held.experimentParts = null; held.injectedIds = []; held.validUntil = ''; }
1319
+ return '';
1320
+ }
1157
1321
  if (held) held.experimentParts = parts;
1158
1322
  /** 兼容旧读取点:整体最早到期只取本轮真正注入的条目,不再被无关试验拖下水。 */
1159
1323
  if (held) {
1160
1324
  const deadlines = parts.items.map((item) => Date.parse(String(item.validUntil))).filter(Number.isFinite);
1161
1325
  if (deadlines.length) held.validUntil = new Date(Math.min(...deadlines)).toISOString();
1162
1326
  }
1163
- return chargeExperiment(win, experimentTextFrom(parts));
1327
+ return admitted;
1164
1328
  }
1165
1329
 
1166
- /** 一次真人请求只路由一次;同一批次重试复用同一 transitionId。 */
1167
- function experimentFor(scope, { sessionId, query, batchKey, messageIds, win }) {
1168
- if (!win || !batchKey) return null;
1169
- let status;
1170
- try { status = evolutionStatus(scope); } catch { return null; }
1171
- if (!status || status.enabled !== true || status.compatible === false) return null;
1172
- if (scope.confirmed !== true) return null;
1173
- let tasks;
1174
- try { tasks = taskState(scope); } catch { return null; }
1330
+ /**
1331
+ * 一次真人请求只路由一次:不管自进化开没开,都先建独立的任务上下文。
1332
+ * 普通记忆按这个上下文判适用;实验规则只是在这个上下文上再加一层可用性。
1333
+ */
1334
+ function runtimeFor(scope, { sessionId, query, batchKey, messageIds, autoRoute = true }) {
1335
+ if (!batchKey) return null;
1336
+ let status = null;
1337
+ try { status = evolutionStatus(scope); } catch { status = null; }
1338
+ const enabled = Boolean(status && status.enabled === true && status.compatible !== false && scope.confirmed === true);
1339
+ /** 任务上下文独立于自进化:项目状态读不出来也要按当前任务判普通记忆,不能整段取消召回。 */
1340
+ let tasks = null;
1341
+ try { tasks = taskState(scope); } catch { tasks = null; }
1342
+ tasks = tasks && typeof tasks === 'object' ? tasks : {};
1175
1343
  const ranges = rangesOf(scope);
1176
- const routed = anchor.routeTask(tasks, {
1177
- projectId: scope.key,
1178
- sessionId,
1179
- batchKey,
1180
- messageIds,
1181
- query,
1182
- registeredSeries: ranges.series,
1183
- defaults: tasks.defaults,
1184
- now: Date.now(),
1185
- });
1344
+ /**
1345
+ * 工具模式不自动路由:上下文照样要有,但不能因为一句点名就改任务状态;
1346
+ * 真正的绑定留给 memory_search bindForRequest,绑定后状态是 proposed。
1347
+ */
1348
+ const routed = autoRoute
1349
+ ? anchor.routeTask(tasks, {
1350
+ projectId: scope.key,
1351
+ sessionId,
1352
+ batchKey,
1353
+ messageIds,
1354
+ query,
1355
+ registeredSeries: ranges.series,
1356
+ defaults: tasks.defaults,
1357
+ now: Date.now(),
1358
+ })
1359
+ : (() => {
1360
+ const sessionAnchor = tasks.sessions && tasks.sessions[sessionId] ? tasks.sessions[sessionId] : null;
1361
+ const episode = sessionAnchor && sessionAnchor.episodeId && tasks.episodes
1362
+ ? tasks.episodes[sessionAnchor.episodeId] || null
1363
+ : null;
1364
+ return { tasks, anchor: sessionAnchor, episode, changed: false, decision: { kind: 'none', reason: 'tool_only' } };
1365
+ })();
1186
1366
  if (routed.changed) {
1187
- const saved = saveTaskState(scope, routed.tasks, { operationId: `task_${batchKey}` });
1188
- if (!saved.ok) return null;
1367
+ /** 落盘失败也不能取消本轮任务判断:进程内上下文仍然有效,只是不留任务历史。 */
1368
+ saveTaskState(scope, routed.tasks, { operationId: `task_${batchKey}` });
1189
1369
  }
1190
1370
  const context = view.trustedContext(scope, anchor.contextOfAnchor(routed.anchor, {}));
1191
1371
  const thanks = anchor.isThanks(query);
1192
1372
  const decision = anchor.decideRead({
1193
1373
  mode: recallMode(),
1194
- enabled: true,
1195
- identityConfirmed: true,
1374
+ enabled,
1375
+ identityConfirmed: scope.confirmed === true,
1196
1376
  anchor: routed.anchor,
1197
1377
  ranges: [...ranges.taskKinds, ...ranges.series],
1198
1378
  thanks,
1199
1379
  boundForRequest: false,
1200
1380
  });
1201
1381
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
1202
- const episode = routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null;
1382
+ const episode = routed.episode
1383
+ || (routed.anchor && routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null);
1203
1384
  let epoch = '';
1204
- try { epoch = String(evolutionStatus(scope).epoch || ''); } catch { epoch = ''; }
1385
+ try { epoch = String(status && status.epoch ? status.epoch : ''); } catch { epoch = ''; }
1386
+ const anchorTask = routed.anchor || {};
1387
+ /** 与自进化开关无关的请求上下文:普通记忆也靠它判断项目、任务与代号。 */
1388
+ const requestContext = anchor.buildRuntimeContext({
1389
+ projectId: scope.key,
1390
+ sessionId,
1391
+ batchKey,
1392
+ messageIds,
1393
+ controlEpoch: epoch,
1394
+ query,
1395
+ anchor: anchorTask,
1396
+ episode,
1397
+ });
1398
+ const task = {
1399
+ ...requestContext.task,
1400
+ kind: anchorTask.taskKind || requestContext.task.kind || context.taskKind || '',
1401
+ seriesId: anchorTask.seriesId || requestContext.task.seriesId || context.seriesId || '',
1402
+ artifactKey: requestContext.task.artifactKey || context.artifactKey || '',
1403
+ phase: requestContext.task.phase || context.phase || '',
1404
+ };
1205
1405
  const held = {
1206
- contextId, projectKey: scope.key, sessionId, batchKey, messageIds, query,
1207
- context, anchor: routed.anchor, ranges, decision, epoch,
1406
+ contextId, requestId: requestContext.requestId, controlEpoch: epoch,
1407
+ projectKey: scope.key, sessionId, batchKey, messageIds, query,
1408
+ context, task, anchor: routed.anchor, ranges, decision, epoch,
1409
+ experimental: enabled,
1208
1410
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
1209
1411
  at: Date.now(),
1210
1412
  };
1211
- win.experiment = held;
1212
- win.contexts.set(contextId, held);
1213
- return { text: renderExperiment(scope, decision, context, held, win), decision, held };
1413
+ return { held, decision, ranges, enabled, status };
1414
+ }
1415
+
1416
+ /** 实验正文只在自进化开着时登记;普通记忆共享同一个任务上下文。 */
1417
+ function experimentFor(scope, runtime, win) {
1418
+ if (!runtime || !runtime.held) return null;
1419
+ const held = runtime.held;
1420
+ if (win) win.runtime = held;
1421
+ if (!runtime.enabled) return null;
1422
+ if (!experimentStillCurrent(scope, held)) return { text: '', held, decision: held.decision };
1423
+ if (win) {
1424
+ win.experiment = held;
1425
+ win.contexts.set(held.contextId, held);
1426
+ }
1427
+ return { text: renderExperiment(scope, held.decision, held.context, held, win), decision: held.decision, held };
1214
1428
  }
1215
1429
 
1216
- /** 自动路径与工具共用一次解析:没有本轮上下文就退回普通检索,绝不猜一个空上下文。 */
1217
1430
  function resolveRequestContext(execution, optionalContextId) {
1218
1431
  const scope = scopeOf(cwdOf(execution));
1219
1432
  const win = windowOf(execution && execution.agent);
1220
1433
  const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
1221
1434
  if (!contextId) {
1435
+ /** 没有显式上下文时,用本请求已经路由出来的任务;这层不依赖自进化开关。 */
1436
+ const live = win && win.runtime ? win.runtime : null;
1437
+ const runtime = live
1438
+ || (win && win.sessionId ? lastRuntime.get(`${scope.key}\u0000${win.sessionId}`) : null);
1439
+ const sameRequest = runtime
1440
+ && runtime.projectKey === scope.key
1441
+ && (!runtime.sessionId || runtime.sessionId === String(win.sessionId || ''))
1442
+ && (runtime === live ? runtime.batchKey === anchor.requestBatchKey(win.messageIds || []) : true);
1443
+ if (sameRequest) {
1444
+ /** 普通记忆可以直接用本请求上下文;实验正文仍要求显式 contextId,不能顺手放行。 */
1445
+ return { ok: true, scope, win, context: runtime.context, held: runtime, experimental: false };
1446
+ }
1222
1447
  return { ok: true, scope, win, context: view.trustedContext(scope, {}), experimental: false };
1223
1448
  }
1224
1449
  /** 先认项目再找本地记录:别的项目的上下文要明确拒绝,不能只报过期。 */
@@ -1371,6 +1596,7 @@ export function apply(ctx, config) {
1371
1596
  scope: value.scope || '',
1372
1597
  revision: value.revision || 0,
1373
1598
  changed: value.changed === true,
1599
+ recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1374
1600
  mode: value.needsScope ? 'needs_scope'
1375
1601
  : value.existingId ? 'duplicate'
1376
1602
  : value.needsResolution ? 'needs_resolution'
@@ -1383,8 +1609,9 @@ export function apply(ctx, config) {
1383
1609
  title: { type: 'string' },
1384
1610
  summary: { type: 'string' },
1385
1611
  when: { type: 'string' },
1612
+ kind: { type: 'string' },
1386
1613
  summaryShortened: { type: 'boolean' },
1387
- fullTextInBody: { type: 'boolean' },
1614
+ fullTextSaved: { type: 'boolean' },
1388
1615
  revision: { type: 'integer' },
1389
1616
  merged: { type: 'boolean' },
1390
1617
  changed: { type: 'boolean' },
@@ -1399,7 +1626,9 @@ export function apply(ctx, config) {
1399
1626
  sameTopic: { type: 'boolean' },
1400
1627
  similarIds: { type: 'string' },
1401
1628
  similar: { type: 'string' },
1629
+ supersededId: { type: 'string' },
1402
1630
  archived: { type: 'string' },
1631
+ pendingConflicts: { type: 'integer' },
1403
1632
  },
1404
1633
  required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1405
1634
  additionalProperties: false,
@@ -1413,12 +1642,12 @@ export function apply(ctx, config) {
1413
1642
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1414
1643
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
1415
1644
  : value.existingId
1416
- ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1645
+ ? `memory · 已有相同记忆:「${value.title}」(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1417
1646
  : value.needsResolution
1418
1647
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1419
1648
  : value.changed === false
1420
- ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1421
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextInBody ? ' · 摘要超过 60 字,完整原句已存进正文' : ''}`,
1649
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
1650
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededId ? ' · 已取代旧说法' : ''}`,
1422
1651
  }],
1423
1652
  },
1424
1653
  execute: async (args, execution) => {
@@ -1486,8 +1715,9 @@ export function apply(ctx, config) {
1486
1715
  crossLayer: Boolean(saved.crossLayer),
1487
1716
  sameTopic: Boolean(saved.sameTopic),
1488
1717
  similarIds: saved.similarList.map((item) => item.id).join('、'),
1489
- similar: saved.similarList.map((item) => `${item.id}「${item.title}」${item.summary ? `|${item.summary}` : ''}`).join(';'),
1718
+ similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
1490
1719
  archived: '',
1720
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1491
1721
  };
1492
1722
  }
1493
1723
  recordCitations(scope, sessionIdOf(execution), [saved.record]);
@@ -1496,8 +1726,9 @@ export function apply(ctx, config) {
1496
1726
  title: saved.record.title,
1497
1727
  summary: saved.record.summary,
1498
1728
  when: saved.record.when || '',
1729
+ kind: saved.record.kind || 'note',
1499
1730
  summaryShortened: Boolean(saved.draft?.summaryShortened),
1500
- fullTextInBody: Boolean(saved.draft?.fullTextInBody),
1731
+ fullTextSaved: Boolean(saved.draft?.fullTextSaved ?? saved.draft?.fullTextInBody),
1501
1732
  revision: saved.record.revision || 1,
1502
1733
  merged: saved.merged,
1503
1734
  changed: !saved.noop,
@@ -1506,8 +1737,10 @@ export function apply(ctx, config) {
1506
1737
  needsScope: false,
1507
1738
  needsResolution: false,
1508
1739
  similarIds: saved.similarList.map((item) => item.id).join('、'),
1740
+ supersededId: String(args.supersedes || ''),
1509
1741
  archived: (saved.archived || []).map((item) => item.title).join('、'),
1510
- ...(saved.similar ? { similar: `${saved.similar.id}「${saved.similar.title}」` } : {}),
1742
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1743
+ ...(saved.similar ? { similar: `「${saved.similar.title}」` } : {}),
1511
1744
  };
1512
1745
  },
1513
1746
  }), 'lite-memory: memory_save');
@@ -1534,7 +1767,7 @@ export function apply(ctx, config) {
1534
1767
  render: (_args, value) => [{
1535
1768
  type: 'text',
1536
1769
  text: value.asked
1537
- ? `memory · suggested "${value.title}" · ${value.id}${value.targetId ? ` · 确认后更新 ${value.targetId}「${value.targetTitle}」` : ''}`
1770
+ ? `memory · suggested "${value.title}"${value.targetId ? ` · 确认后更新「${value.targetTitle}」` : ''}`
1538
1771
  : `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
1539
1772
  }],
1540
1773
  },
@@ -1558,7 +1791,10 @@ export function apply(ctx, config) {
1558
1791
  description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1559
1792
  parameters: SEARCH_PARAMS,
1560
1793
  output: {
1561
- presentationMeta: (_args, value) => ({ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [] }),
1794
+ presentationMeta: (_args, value) => ({
1795
+ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [],
1796
+ noTriggerReason: value.noTriggerReason || '',
1797
+ }),
1562
1798
  schema: {
1563
1799
  type: 'object',
1564
1800
  properties: {
@@ -1567,6 +1803,7 @@ export function apply(ctx, config) {
1567
1803
  text: { type: 'string' },
1568
1804
  status: { type: 'string', enum: ['ok', 'budget_exhausted', 'memory_disabled', 'needs_context'] },
1569
1805
  reasonCode: { type: 'string' },
1806
+ noTriggerReason: { type: 'string' },
1570
1807
  refs: {
1571
1808
  type: 'array',
1572
1809
  items: {
@@ -1578,8 +1815,11 @@ export function apply(ctx, config) {
1578
1815
  revision: { type: 'integer' },
1579
1816
  status: { type: 'string' },
1580
1817
  origin: { type: 'string' },
1818
+ kind: { type: 'string' },
1819
+ when: { type: 'string' },
1820
+ updated: { type: 'string' }, // ui-copy-ignore
1581
1821
  },
1582
- required: ['id', 'title', 'scope', 'revision', 'status', 'origin'],
1822
+ required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
1583
1823
  additionalProperties: false,
1584
1824
  },
1585
1825
  },
@@ -1618,13 +1858,16 @@ export function apply(ctx, config) {
1618
1858
  }
1619
1859
  const scope = resolved.scope;
1620
1860
  const context = resolved.context;
1861
+ const gateRuntime = resolved.held || win?.runtime
1862
+ || { projectId: scope.key, context, experimental: resolved.experimental === true };
1621
1863
  /** 检索只列当前任务真能用的条目;先取全量候选,过滤后再截断,避免不可用条目挤掉后面的命中。 */
1622
1864
  const limit = args.limit ?? 3;
1623
1865
  const rawHits = search(scope, args.query, 50, {
1624
1866
  context,
1625
1867
  history: args.history === true,
1626
1868
  allowExperimental: resolved.experimental === true,
1627
- }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key);
1869
+ }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key)
1870
+ .filter((item) => item.archived || view.eligibility(item, gateRuntime, 'search').ok);
1628
1871
  const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
1629
1872
  const withheld = rawHits.filter((item) => !item.archived && item.status !== 'ok');
1630
1873
  const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
@@ -1647,6 +1890,7 @@ export function apply(ctx, config) {
1647
1890
  : '有相关的项目记忆,但当前任务明确不适用。',
1648
1891
  status: unresolved ? 'needs_context' : 'ok',
1649
1892
  reasonCode: unresolved ? 'applicability_unresolved' : 'not_applicable',
1893
+ noTriggerReason: unresolved ? '适用条件未确认' : '当前任务不适用',
1650
1894
  refs: [],
1651
1895
  };
1652
1896
  if (win) {
@@ -1659,7 +1903,7 @@ export function apply(ctx, config) {
1659
1903
  }
1660
1904
  /** 没命中是正常结果,不是额度问题;空响应同样记一次本请求开销。 */
1661
1905
  if (!hits.length) {
1662
- const empty = { count: 0, chars: 0, text: '', status: 'ok', refs: [] };
1906
+ const empty = { count: 0, chars: 0, text: '', status: 'ok', reasonCode: 'no_match', noTriggerReason: '没有匹配的记忆', refs: [] };
1663
1907
  if (win) {
1664
1908
  const bytes = jsonBytes(empty);
1665
1909
  if (!affordable(win, bytes)) return { ...empty, status: 'budget_exhausted' };
@@ -1699,6 +1943,7 @@ export function apply(ctx, config) {
1699
1943
  scope: { type: 'string' },
1700
1944
  scopeName: { type: 'string' },
1701
1945
  origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
1946
+ kind: { type: 'string' },
1702
1947
  status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
1703
1948
  body: { type: 'string' },
1704
1949
  links: { type: 'string' },
@@ -1716,11 +1961,11 @@ export function apply(ctx, config) {
1716
1961
  : value.repeat
1717
1962
  ? 'memory · 本请求已读过这条记忆'
1718
1963
  : value.body
1719
- ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1964
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''}${statusLabel(value.status)}\n${value.body}`
1720
1965
  : value.links !== undefined && value.status === 'ok'
1721
1966
  ? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
1722
1967
  : value.status === 'needs_context'
1723
- ? 'memory · 这条记忆的适用条件还没确认'
1968
+ ? 'memory · 这条记忆的适用条件还没确认,先说明当前任务再读'
1724
1969
  : value.status === 'not_applicable'
1725
1970
  ? 'memory · 这条记忆不适用于当前任务'
1726
1971
  : value.status === 'archived'
@@ -1736,6 +1981,7 @@ export function apply(ctx, config) {
1736
1981
  scope: value.scope || '',
1737
1982
  revision: value.revision || 0,
1738
1983
  origin: value.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
1984
+ recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1739
1985
  status: value.status || 'ok',
1740
1986
  }),
1741
1987
  },
@@ -1749,8 +1995,14 @@ export function apply(ctx, config) {
1749
1995
  const allowExperimental = resolved.experimental === true;
1750
1996
  const agent = execution?.agent;
1751
1997
  const win = windowOf(agent);
1998
+ const gateRuntime = resolved.held || win?.runtime
1999
+ || { projectId: scope.key, context, experimental: resolved.experimental === true };
1752
2000
  if (args.view === 'links') {
1753
- const found = readLinks(scope, args.id, { context, allowExperimental });
2001
+ const source = locate(scope, args.id);
2002
+ if (source && !view.eligibility(source.record, gateRuntime, 'read').ok) {
2003
+ return { id: '', scope: '', status: 'needs_context', links: '' };
2004
+ }
2005
+ const found = readLinks(scope, args.id, { context, allowExperimental, runtime: gateRuntime, now: Date.now() });
1754
2006
  if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1755
2007
  if (found.status !== 'ok') {
1756
2008
  const withheld = { id: '', scope: '', status: found.status, links: '' };
@@ -1778,20 +2030,8 @@ export function apply(ctx, config) {
1778
2030
  }
1779
2031
  const record = readEntry(scope, args.id, { context, allowExperimental });
1780
2032
  if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
1781
- if (record.origin === 'project_evolution' && record.ownerProjectId && record.ownerProjectId !== scope.key) {
1782
- return { id: '', scope: record.scope, status: 'not_applicable', body: '' };
1783
- }
1784
- if (record.origin === 'project_evolution' && !allowExperimental) {
1785
- return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1786
- }
1787
- /** 暂停、过期或条件不匹配的实验条目只给状态,不给标题和正文。 */
1788
- if (record.origin === 'project_evolution' && !view.experimentalUsable(record)) {
1789
- return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1790
- }
1791
- const learningState = record.learning && typeof record.learning === 'object' ? record.learning : {};
1792
- if (record.origin === 'project_evolution' && (learningState.state === 'paused' || learningState.blockedReason)) {
1793
- return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1794
- }
2033
+ const gate = view.eligibility(record, gateRuntime, 'read');
2034
+ if (!gate.ok) return { id: '', scope: record.scope, status: gate.status, body: '' };
1795
2035
  /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
1796
2036
  if (record.status !== 'ok') {
1797
2037
  return { id: '', scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
@@ -1806,6 +2046,7 @@ export function apply(ctx, config) {
1806
2046
  scope: record.scope,
1807
2047
  scopeName,
1808
2048
  status: record.status,
2049
+ kind: record.kind || 'note',
1809
2050
  body,
1810
2051
  };
1811
2052
  if (win) {
@@ -1853,12 +2094,12 @@ export function apply(ctx, config) {
1853
2094
  text: value.mode === 'memory_disabled'
1854
2095
  ? 'memory · 本项目记忆已关闭,先在面板打开'
1855
2096
  : value.mode === 'purged'
1856
- ? `memory · forgotten "${value.title}" · ${value.id}`
2097
+ ? `memory · forgotten "${value.title}"`
1857
2098
  : value.mode === 'purge_pending'
1858
- ? `memory · 清除未完成 · ${value.id},重启后会继续`
2099
+ ? `memory · 清除未完成 · 「${value.title || '这条记忆'}」,重启后会继续`
1859
2100
  : value.mode === 'needs_resolution'
1860
- ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
1861
- : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
2101
+ ? `memory · 先确认是归档还是彻底忘掉 · 「${value.title || '这条记忆'}」`
2102
+ : `memory · archived ${withTag(value, `"${value.title}"`)},可找回`,
1862
2103
  }],
1863
2104
  presentationMeta: (_args, value) => ({
1864
2105
  kind: 'memory-forget',
@@ -1875,7 +2116,7 @@ export function apply(ctx, config) {
1875
2116
  const located = locate(scope, args.id);
1876
2117
  if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
1877
2118
  if (!args.mode) {
1878
- return { id: args.id, mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
2119
+ return { id: args.id, title: located.record.title || '', mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
1879
2120
  }
1880
2121
  let dropped;
1881
2122
  try {
@@ -1963,7 +2204,6 @@ export function apply(ctx, config) {
1963
2204
  });
1964
2205
 
1965
2206
  if (typeof ctx.on === 'function') ctx.on('agent/pre-step', async (payload, next) => {
1966
- const startedAt = Date.now();
1967
2207
  const agent = payload?.agent;
1968
2208
  syncMemoryTools(agent);
1969
2209
  let decision;
@@ -1973,6 +2213,8 @@ export function apply(ctx, config) {
1973
2213
  clearPrepared(agent);
1974
2214
  throw error;
1975
2215
  }
2216
+ /** 宿主等待不算本地召回时间:额度从拿到这一步的开始算。 */
2217
+ const startedAt = Date.now();
1976
2218
  try {
1977
2219
  if (!agent || decision?.kind !== 'enter') {
1978
2220
  clearPrepared(agent);
@@ -2018,7 +2260,8 @@ export function apply(ctx, config) {
2018
2260
  }
2019
2261
  if (fingerprint !== previous.fingerprint) {
2020
2262
  const win = windowOf(agent);
2021
- const snapshot = projectRecall(scope, previous.query, view.trustedContext(scope, {}), win);
2263
+ if (win) win.autoBudget = emptyAutoBudget();
2264
+ const snapshot = projectRecall(scope, previous.query, win && win.runtime ? win.runtime.context : view.trustedContext(scope, {}), win, win && win.runtime ? win.runtime : null);
2022
2265
  let experimentText = '';
2023
2266
  if (win && win.experiment) {
2024
2267
  experimentText = renderExperiment(scope, win.experiment.decision, win.experiment.context, win.experiment, win);
@@ -2042,15 +2285,16 @@ export function apply(ctx, config) {
2042
2285
  /** 工具模式也要开额度窗口:只是不自动注入,工具调用同样受每请求预算约束。 */
2043
2286
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
2044
2287
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2288
+ const budgetBefore = win ? {
2289
+ bytes: win.bytes, bodies: win.bodies, reads: win.reads,
2290
+ provided: new Map(win.provided), readBodies: new Map(win.readBodies),
2291
+ recallHeader: win.recallHeader, experimentBytes: win.experimentBytes,
2292
+ autoBudget: win.autoBudget ? JSON.parse(JSON.stringify(win.autoBudget)) : null,
2293
+ } : null;
2045
2294
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2046
2295
  const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2047
2296
  if (win && query) win.query = query;
2048
2297
 
2049
- if (recallMode() !== 'adaptive') {
2050
- clearPrepared(agent);
2051
- return decision;
2052
- }
2053
-
2054
2298
  if (!query) {
2055
2299
  clearPrepared(agent);
2056
2300
  return decision;
@@ -2065,29 +2309,61 @@ export function apply(ctx, config) {
2065
2309
  clearPrepared(agent);
2066
2310
  return decision;
2067
2311
  }
2312
+ const batchKey = anchor.requestBatchKey(ids);
2313
+ /**
2314
+ * 工具模式也必须先有本请求上下文:memory_search/read/links 靠同一个 contextId 判项目和任务,
2315
+ * 不能等到自动召回分支才建,否则工具模式永远拿不到可核对的任务。
2316
+ */
2317
+ const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
2318
+ if (win && runtime) win.runtime = runtime.held;
2319
+ rememberRuntime(scope, sessionId, runtime && runtime.held);
2320
+ if (recallMode() !== 'adaptive') {
2321
+ clearPrepared(agent);
2322
+ return decision;
2323
+ }
2068
2324
  const context = view.trustedContext(scope, {});
2069
2325
  const fingerprint = fingerprintOf(scope);
2070
2326
  const controlEpoch = controlStatus(scope).epoch || '';
2071
- const batchKey = anchor.requestBatchKey(ids);
2072
2327
  dropStaleCandidate(sessionId, batchKey);
2073
- const experiment = experimentFor(scope, { sessionId, query, batchKey, messageIds: ids, win });
2074
- const remembered = experiment && experiment.held
2075
- ? rememberFeedback(scope, sessionId, fresh, experiment.held, turn)
2328
+ /** 任务上下文独立于自进化开关:普通记忆按当前任务判适用,实验规则再叠一层可用性。 */
2329
+ const auto = emptyAutoBudget();
2330
+ if (win) win.autoBudget = auto;
2331
+ const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2332
+ const recalledContext = runtime ? runtime.held.context : context;
2333
+ const remembered = runtime && runtime.enabled
2334
+ ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
2076
2335
  : null;
2077
- const snapshot = projectRecall(scope, query, context, win);
2078
- if (generation !== generations.get(agent)) return decision;
2336
+ const snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2337
+ /** 没发布出去的投影要退还额度:不能因为一次超时把本请求的召回额度耗掉。 */
2338
+ const rollbackBudget = () => {
2339
+ if (!win || !budgetBefore) return;
2340
+ win.bytes = budgetBefore.bytes;
2341
+ win.bodies = budgetBefore.bodies;
2342
+ win.reads = budgetBefore.reads;
2343
+ win.provided = budgetBefore.provided;
2344
+ win.readBodies = budgetBefore.readBodies;
2345
+ win.recallHeader = budgetBefore.recallHeader;
2346
+ win.experimentBytes = budgetBefore.experimentBytes;
2347
+ win.autoBudget = budgetBefore.autoBudget || emptyAutoBudget();
2348
+ win.experiment = null;
2349
+ win.contexts.clear();
2350
+ };
2351
+ if (generation !== generations.get(agent)) { rollbackBudget(); return decision; }
2079
2352
  /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
2080
2353
  if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
2354
+ rollbackBudget();
2081
2355
  clearPrepared(agent);
2082
2356
  return decision;
2083
2357
  }
2084
2358
  /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
2085
2359
  if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
2360
+ rollbackBudget();
2086
2361
  clearPrepared(agent);
2087
2362
  return decision;
2088
2363
  }
2089
2364
  /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2090
2365
  if (!snapshot && !experiment) {
2366
+ rollbackBudget();
2091
2367
  clearPrepared(agent);
2092
2368
  return decision;
2093
2369
  }
@@ -2196,6 +2472,13 @@ export function apply(ctx, config) {
2196
2472
  const route = routeForSession(session);
2197
2473
  if (!route) return;
2198
2474
  const candidate = pending.candidate;
2475
+ /** 候选进入整理前再走一次统一准入:项目/作品身份、作用域与反馈类型都对上才花配额。 */
2476
+ const admission = learning.admissionOf(candidate, {
2477
+ projectId: scope.key,
2478
+ sessionId: candidate.sessionId,
2479
+ anchor: pending.held?.anchor || null,
2480
+ }, 'collect');
2481
+ if (!admission || admission.ok !== true) return;
2199
2482
  const operationId = `learn_${crypto.createHash('sha256')
2200
2483
  .update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
2201
2484
  .digest('hex').slice(0, 16)}`;
@@ -2254,6 +2537,12 @@ export function apply(ctx, config) {
2254
2537
  }
2255
2538
  const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
2256
2539
  if (!parsed.ok) throw new Error(parsed.reason);
2540
+ /** 用户已经把要求说成长期规则,模型仍返回空时至少留一条规则,不让这条反馈无声消失。 */
2541
+ const changes = parsed.changes.length
2542
+ ? parsed.changes
2543
+ : (candidate.cue?.explicitLongTerm && candidate.cue?.type === 'spec_change'
2544
+ ? [learning.pendingChangeFrom(candidate)].filter(Boolean)
2545
+ : []);
2257
2546
  const commit = commitLearning(scope, {
2258
2547
  operationId,
2259
2548
  epoch: begin.epoch,
@@ -2263,8 +2552,9 @@ export function apply(ctx, config) {
2263
2552
  sessionId: candidate.sessionId,
2264
2553
  messageIds: candidate.messageIds,
2265
2554
  feedbackText: candidate.text,
2555
+ sourceExcerpt: candidate.sourceExcerpt || learning.sourceExcerptOf(candidate.text),
2266
2556
  dimension: candidate.cue?.dimension || '',
2267
- changes: parsed.changes,
2557
+ changes,
2268
2558
  preparedTargets,
2269
2559
  usage: learning.usageOf(chunks),
2270
2560
  explicitLongTerm: Boolean(candidate.cue.explicitLongTerm),