dsh-lite-memory 0.16.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,9 @@ 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 : '',
630
647
  }));
631
648
  }
632
649
 
@@ -872,6 +889,14 @@ export function apply(ctx, config) {
872
889
 
873
890
  const windowOf = (agent) => windows.get(agentKey(agent)) || null;
874
891
 
892
+ /** 自动注入账本:普通段与实验段共用 3 条 / 1200 字节,量的是拼接后的完整文本。 */
893
+ const emptyAutoBudget = () => ({
894
+ normalText: '', experimentText: '',
895
+ normalCards: 0, experimentCards: 0,
896
+ cards: 0, cardBytes: 0, experimentBytes: 0, totalBytes: 0,
897
+ });
898
+
899
+
875
900
  /** 预算按规范化 JSON 字节算:转义、中文、换行都算进模型真正收到的内容。 */
876
901
  const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value));
877
902
 
@@ -892,7 +917,7 @@ export function apply(ctx, config) {
892
917
  signature, sessionId, turn, messageIds,
893
918
  bytes: 0, bodies: 0, reads: 0,
894
919
  provided: new Map(), readBodies: new Map(), at: Date.now(),
895
- experiment: null, contexts: new Map(),
920
+ experiment: null, contexts: new Map(), runtime: null, autoBudget: null,
896
921
  };
897
922
  windows.set(key, next);
898
923
  return next;
@@ -917,6 +942,11 @@ export function apply(ctx, config) {
917
942
  }
918
943
 
919
944
  /** 两层都算:任何一层换库、换代或清空,之前准备的卡片都要失效。 */
945
+ /**
946
+ * 同一请求的召回快照只要内容没变就保持原样:命中次数、使用时间这类使用记录
947
+ * 不参与指纹,否则模型多读一次就会触发重投影。真正改变应看条目内容版本、
948
+ * 启停/控制代号与自进化代号。
949
+ */
920
950
  function fingerprintOf(scope) {
921
951
  try {
922
952
  return [scope, globalScopeView()].map((source) => {
@@ -924,7 +954,11 @@ export function apply(ctx, config) {
924
954
  const meta = index.__meta || {};
925
955
  const evolutionEpoch = index.__evolution && index.__evolution.epoch ? index.__evolution.epoch : '';
926
956
  const controlEpoch = index.__control && index.__control.epoch ? index.__control.epoch : '';
927
- return `${index.scope}:${meta.storeId || ''}:${index.generation}:${index.epoch}:${evolutionEpoch}:${controlEpoch}`;
957
+ const content = (index.entries || [])
958
+ .map((entry) => `${entry.id}:${entry.revision || 1}:${entry.status || 'active'}:${entry.updated || ''}:${entry.topicKey || ''}`)
959
+ .sort()
960
+ .join(',');
961
+ return `${index.scope}:${meta.storeId || ''}:${evolutionEpoch}:${controlEpoch}:${content}`;
928
962
  }).join('|');
929
963
  } catch {
930
964
  return '';
@@ -934,7 +968,8 @@ export function apply(ctx, config) {
934
968
  const globalScopeView = () => globalScope();
935
969
 
936
970
  /** 纯内存:读两层当前投影,先判适用再排序,整卡装预算。 */
937
- function projectRecall(scope, query, context, win) {
971
+ function projectRecall(scope, query, context, win, runtime) {
972
+ context = view.contextWithTask(context, runtime && runtime.task);
938
973
  const layers = { project: [], global: [] };
939
974
  const seen = new Set();
940
975
  for (const source of [scope, globalScope()]) {
@@ -958,38 +993,61 @@ export function apply(ctx, config) {
958
993
  records.push(group.record);
959
994
  layerOf.set(group.record.id, group.scope === 'global' ? 'global' : 'project');
960
995
  }
961
- const ranked = view.rank(records, query, { limit: 8, requireApplicable: true, context });
962
- const headerBytes = Buffer.byteLength(RECALL_HEADER);
963
- let used = headerBytes;
996
+ const ranked = view.rank(records, query, {
997
+ limit: 8, requireApplicable: true, context,
998
+ runtime: runtime || (win && win.runtime) || null,
999
+ task: runtime && runtime.task ? runtime.task : null,
1000
+ });
1001
+ /** 普通记忆与实验规则共用一份自动注入预算:总条数和最终完整文本字节一起算。 */
1002
+ const auto = win && win.autoBudget ? win.autoBudget : null;
964
1003
  /** 卡片上限含包装;共享请求账本也要把标题行算进去,不能只算卡片行。 */
965
1004
  let headerCharged = win ? win.recallHeader === true : false;
966
1005
  const lines = [];
967
1006
  const cards = [];
968
1007
  for (const row of ranked) {
969
- if (cards.length >= view.CARD.max) break;
1008
+ const usedCards = cards.length + (auto ? (auto.experimentCards || 0) : 0);
1009
+ if (usedCards >= view.CARD.max) break;
970
1010
  const card = view.cardOf(row.record, layerOf.get(row.record.id));
971
1011
  if (card.scope === 'project') card.scopeName = projectNameOf(scope);
972
1012
  const line = renderRecallCard(card);
973
- const size = Buffer.byteLength(`\n${line}`);
974
- if (used + size > view.CARD.bytes) continue;
1013
+ const nextText = `${RECALL_HEADER}\n${[...lines, line].join('\n')}`;
1014
+ if (auto) {
1015
+ /** 实验段可能先渲染:组装成最终两段再量,超了就跳过这张,不把标题留在模型侧。 */
1016
+ const admitted = admitAuto(win, {
1017
+ normal: nextText,
1018
+ experiment: auto.experimentText || '',
1019
+ normalCards: cards.length + 1,
1020
+ experimentCards: auto.experimentCards || 0,
1021
+ });
1022
+ if (!admitted.ok) continue;
1023
+ } else {
1024
+ const size = Buffer.byteLength(nextText);
1025
+ if (size > view.CARD.bytes) continue;
1026
+ const cost = Buffer.byteLength(`\n${line}`) + (headerCharged ? 0 : Buffer.byteLength(RECALL_HEADER));
1027
+ if (win && !affordable(win, cost)) continue;
1028
+ if (win) win.bytes += cost;
1029
+ }
975
1030
  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
- }
1031
+ if (win) win.provided.set(card.id, revision);
1032
+ if (win && !headerCharged) {
1033
+ headerCharged = true;
1034
+ win.recallHeader = true;
986
1035
  }
987
- used += size;
988
1036
  lines.push(line);
989
1037
  cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
990
1038
  }
1039
+ const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1040
+ if (auto) {
1041
+ /** 没有任何普通卡片时也要把已有实验段记账成当前状态。 */
1042
+ admitAuto(win, {
1043
+ normal: lines.length ? finalText : (auto.normalText || ''),
1044
+ experiment: auto.experimentText || '',
1045
+ normalCards: cards.length,
1046
+ experimentCards: auto.experimentCards || 0,
1047
+ });
1048
+ }
991
1049
  if (!cards.length) return null;
992
- return { text: `${RECALL_HEADER}\n${lines.join('\n')}`, cards, bytes: used, query };
1050
+ return { text: finalText, cards, bytes: Buffer.byteLength(finalText), query };
993
1051
  }
994
1052
 
995
1053
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1037,9 +1095,62 @@ export function apply(ctx, config) {
1037
1095
  .join('、');
1038
1096
  }
1039
1097
 
1040
- /** 实验内容单独记账:同一请求里再次渲染只补差额,不重复占满预算。 */
1041
- function chargeExperiment(win, text) {
1098
+ /**
1099
+ * 自动注入只有一份预算:普通卡片与实验规则合计最多 3 条 / 1200 字节。
1100
+ * 这里按 provider 最终会看到的完整文本量字节(含标题行、ID、换行和两段之间的空行),
1101
+ * 先组装再测量,不能只量卡片正文再拼。
1102
+ */
1103
+ function autoTextOf(win, normal, experiment) {
1104
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1105
+ const n = normal === undefined ? String(auto?.normalText || '') : String(normal || '');
1106
+ const e = experiment === undefined ? String(auto?.experimentText || '') : String(experiment || '');
1107
+ return { normal: n, experiment: e, text: [e, n].filter(Boolean).join('\n\n') };
1108
+ }
1109
+
1110
+ function autoBytesOf(win, normal, experiment) {
1111
+ return Buffer.byteLength(autoTextOf(win, normal, experiment).text);
1112
+ }
1113
+
1114
+ /**
1115
+ * 原子准入:量的是最终完整文本和总条数;通过才写入预算账本。
1116
+ * 任何一段超限都整段回退,不截半句前提、不把标题留在模型侧。
1117
+ */
1118
+ function admitAuto(win, { normal, experiment, normalCards, experimentCards } = {}) {
1119
+ if (!win || !win.autoBudget) return { ok: false, bytes: 0 };
1120
+ const auto = win.autoBudget;
1121
+ const parts = autoTextOf(win, normal, experiment);
1122
+ const cards = (normalCards === undefined ? (auto.normalCards || 0) : normalCards)
1123
+ + (experimentCards === undefined ? (auto.experimentCards || 0) : experimentCards);
1124
+ const bytes = Buffer.byteLength(parts.text);
1125
+ if (cards > view.CARD.max || bytes > view.CARD.bytes) return { ok: false, bytes, cards };
1126
+ const previousTotal = Number(auto.totalBytes || 0);
1127
+ if (!affordable(win, bytes - previousTotal)) return { ok: false, bytes, cards };
1128
+ auto.normalText = parts.normal;
1129
+ auto.experimentText = parts.experiment;
1130
+ auto.normalCards = normalCards === undefined ? (auto.normalCards || 0) : normalCards;
1131
+ auto.experimentCards = experimentCards === undefined ? (auto.experimentCards || 0) : experimentCards;
1132
+ auto.cards = cards;
1133
+ auto.cardBytes = Buffer.byteLength(parts.normal);
1134
+ auto.experimentBytes = Buffer.byteLength(parts.experiment);
1135
+ auto.totalBytes = bytes;
1136
+ win.bytes += bytes - previousTotal;
1137
+ return { ok: true, bytes, cards };
1138
+ }
1139
+
1140
+ /** 实验内容单独记账:同一请求里再次渲染替换旧段,只补差额。 */
1141
+ function chargeExperiment(win, text, cards = 1) {
1042
1142
  if (!text) return '';
1143
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1144
+ if (auto) {
1145
+ const admitted = admitAuto(win, {
1146
+ normal: auto.normalText || '',
1147
+ experiment: text,
1148
+ normalCards: auto.normalCards || 0,
1149
+ experimentCards: cards,
1150
+ });
1151
+ if (!admitted.ok) return '';
1152
+ return text;
1153
+ }
1043
1154
  const bytes = Buffer.byteLength(`\n\n${text}`);
1044
1155
  const previous = win ? (win.experimentBytes || 0) : 0;
1045
1156
  if (bytes > EXPERIMENT_BYTES) return '';
@@ -1128,8 +1239,16 @@ export function apply(ctx, config) {
1128
1239
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
1129
1240
  }
1130
1241
  const parts = { header: EXPERIMENT_RULES_HEADER, items: [], tail: held && held.contextId ? `\ncontextId=${held.contextId}` : '' };
1242
+ /** 与普通记忆共用 3 张卡片 / 1200 字节:普通卡片先占掉的位子和字节,实验规则不能再拿。 */
1243
+ const auto = win && win.autoBudget ? win.autoBudget : null;
1244
+ const cardCap = auto
1245
+ ? Math.max(0, view.CARD.max - (auto.normalCards || 0) - (auto.experimentCards || 0))
1246
+ : view.CARD.max;
1247
+ const autoCap = auto
1248
+ ? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
1249
+ : EXPERIMENT_BYTES;
1131
1250
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1132
- for (const record of matched.slice(0, 3)) {
1251
+ for (const record of matched.slice(0, cardCap)) {
1133
1252
  const body = String(record.body || '');
1134
1253
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
1135
1254
  const conditions = conditionLabel(record.conditions);
@@ -1141,7 +1260,7 @@ export function apply(ctx, config) {
1141
1260
  ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1142
1261
  : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
1143
1262
  const size = Buffer.byteLength(`\n${line}`);
1144
- if (used + size > EXPERIMENT_BYTES) break;
1263
+ if (used + size > autoCap) break;
1145
1264
  used += size;
1146
1265
  const trialLearning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1147
1266
  const trialUntil = trialLearning.state === 'trial' && Number.isFinite(Date.parse(String(trialLearning.trialUntil || '')))
@@ -1154,71 +1273,136 @@ export function apply(ctx, config) {
1154
1273
  return chargeExperiment(win, '有些实验规则的适用条件还没确认,先按用户这次说的做,别把它们当本轮依据。');
1155
1274
  }
1156
1275
  if (!parts.items.length) return '';
1276
+ /** 先按最终整段文本过预算,再登记可发布内容;没通过就一条都不留。 */
1277
+ const admitted = chargeExperiment(win, experimentTextFrom(parts), parts.items.length);
1278
+ if (!admitted) {
1279
+ if (held) { held.experimentParts = null; held.injectedIds = []; held.validUntil = ''; }
1280
+ return '';
1281
+ }
1157
1282
  if (held) held.experimentParts = parts;
1158
1283
  /** 兼容旧读取点:整体最早到期只取本轮真正注入的条目,不再被无关试验拖下水。 */
1159
1284
  if (held) {
1160
1285
  const deadlines = parts.items.map((item) => Date.parse(String(item.validUntil))).filter(Number.isFinite);
1161
1286
  if (deadlines.length) held.validUntil = new Date(Math.min(...deadlines)).toISOString();
1162
1287
  }
1163
- return chargeExperiment(win, experimentTextFrom(parts));
1288
+ return admitted;
1164
1289
  }
1165
1290
 
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; }
1291
+ /**
1292
+ * 一次真人请求只路由一次:不管自进化开没开,都先建独立的任务上下文。
1293
+ * 普通记忆按这个上下文判适用;实验规则只是在这个上下文上再加一层可用性。
1294
+ */
1295
+ function runtimeFor(scope, { sessionId, query, batchKey, messageIds, autoRoute = true }) {
1296
+ if (!batchKey) return null;
1297
+ let status = null;
1298
+ try { status = evolutionStatus(scope); } catch { status = null; }
1299
+ const enabled = Boolean(status && status.enabled === true && status.compatible !== false && scope.confirmed === true);
1300
+ /** 任务上下文独立于自进化:项目状态读不出来也要按当前任务判普通记忆,不能整段取消召回。 */
1301
+ let tasks = null;
1302
+ try { tasks = taskState(scope); } catch { tasks = null; }
1303
+ tasks = tasks && typeof tasks === 'object' ? tasks : {};
1175
1304
  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
- });
1305
+ /**
1306
+ * 工具模式不自动路由:上下文照样要有,但不能因为一句点名就改任务状态;
1307
+ * 真正的绑定留给 memory_search bindForRequest,绑定后状态是 proposed。
1308
+ */
1309
+ const routed = autoRoute
1310
+ ? anchor.routeTask(tasks, {
1311
+ projectId: scope.key,
1312
+ sessionId,
1313
+ batchKey,
1314
+ messageIds,
1315
+ query,
1316
+ registeredSeries: ranges.series,
1317
+ defaults: tasks.defaults,
1318
+ now: Date.now(),
1319
+ })
1320
+ : (() => {
1321
+ const sessionAnchor = tasks.sessions && tasks.sessions[sessionId] ? tasks.sessions[sessionId] : null;
1322
+ const episode = sessionAnchor && sessionAnchor.episodeId && tasks.episodes
1323
+ ? tasks.episodes[sessionAnchor.episodeId] || null
1324
+ : null;
1325
+ return { tasks, anchor: sessionAnchor, episode, changed: false, decision: { kind: 'none', reason: 'tool_only' } };
1326
+ })();
1186
1327
  if (routed.changed) {
1187
- const saved = saveTaskState(scope, routed.tasks, { operationId: `task_${batchKey}` });
1188
- if (!saved.ok) return null;
1328
+ /** 落盘失败也不能取消本轮任务判断:进程内上下文仍然有效,只是不留任务历史。 */
1329
+ saveTaskState(scope, routed.tasks, { operationId: `task_${batchKey}` });
1189
1330
  }
1190
1331
  const context = view.trustedContext(scope, anchor.contextOfAnchor(routed.anchor, {}));
1191
1332
  const thanks = anchor.isThanks(query);
1192
1333
  const decision = anchor.decideRead({
1193
1334
  mode: recallMode(),
1194
- enabled: true,
1195
- identityConfirmed: true,
1335
+ enabled,
1336
+ identityConfirmed: scope.confirmed === true,
1196
1337
  anchor: routed.anchor,
1197
1338
  ranges: [...ranges.taskKinds, ...ranges.series],
1198
1339
  thanks,
1199
1340
  boundForRequest: false,
1200
1341
  });
1201
1342
  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;
1343
+ const episode = routed.episode
1344
+ || (routed.anchor && routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null);
1203
1345
  let epoch = '';
1204
- try { epoch = String(evolutionStatus(scope).epoch || ''); } catch { epoch = ''; }
1346
+ try { epoch = String(status && status.epoch ? status.epoch : ''); } catch { epoch = ''; }
1347
+ const anchorTask = routed.anchor || {};
1348
+ /** 与自进化开关无关的请求上下文:普通记忆也靠它判断项目、任务与代号。 */
1349
+ const requestContext = anchor.buildRuntimeContext({
1350
+ projectId: scope.key,
1351
+ sessionId,
1352
+ batchKey,
1353
+ messageIds,
1354
+ controlEpoch: epoch,
1355
+ query,
1356
+ anchor: anchorTask,
1357
+ episode,
1358
+ });
1359
+ const task = {
1360
+ ...requestContext.task,
1361
+ kind: anchorTask.taskKind || requestContext.task.kind || context.taskKind || '',
1362
+ seriesId: anchorTask.seriesId || requestContext.task.seriesId || context.seriesId || '',
1363
+ artifactKey: requestContext.task.artifactKey || context.artifactKey || '',
1364
+ phase: requestContext.task.phase || context.phase || '',
1365
+ };
1205
1366
  const held = {
1206
- contextId, projectKey: scope.key, sessionId, batchKey, messageIds, query,
1207
- context, anchor: routed.anchor, ranges, decision, epoch,
1367
+ contextId, requestId: requestContext.requestId, controlEpoch: epoch,
1368
+ projectKey: scope.key, sessionId, batchKey, messageIds, query,
1369
+ context, task, anchor: routed.anchor, ranges, decision, epoch,
1370
+ experimental: enabled,
1208
1371
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
1209
1372
  at: Date.now(),
1210
1373
  };
1211
- win.experiment = held;
1212
- win.contexts.set(contextId, held);
1213
- return { text: renderExperiment(scope, decision, context, held, win), decision, held };
1374
+ return { held, decision, ranges, enabled, status };
1375
+ }
1376
+
1377
+ /** 实验正文只在自进化开着时登记;普通记忆共享同一个任务上下文。 */
1378
+ function experimentFor(scope, runtime, win) {
1379
+ if (!runtime || !runtime.held) return null;
1380
+ const held = runtime.held;
1381
+ if (win) win.runtime = held;
1382
+ if (!runtime.enabled) return null;
1383
+ if (!experimentStillCurrent(scope, held)) return { text: '', held, decision: held.decision };
1384
+ if (win) {
1385
+ win.experiment = held;
1386
+ win.contexts.set(held.contextId, held);
1387
+ }
1388
+ return { text: renderExperiment(scope, held.decision, held.context, held, win), decision: held.decision, held };
1214
1389
  }
1215
1390
 
1216
- /** 自动路径与工具共用一次解析:没有本轮上下文就退回普通检索,绝不猜一个空上下文。 */
1217
1391
  function resolveRequestContext(execution, optionalContextId) {
1218
1392
  const scope = scopeOf(cwdOf(execution));
1219
1393
  const win = windowOf(execution && execution.agent);
1220
1394
  const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
1221
1395
  if (!contextId) {
1396
+ /** 没有显式上下文时,用本请求已经路由出来的任务;这层不依赖自进化开关。 */
1397
+ const runtime = win && win.runtime;
1398
+ const sameRequest = runtime
1399
+ && runtime.projectKey === scope.key
1400
+ && (!runtime.sessionId || runtime.sessionId === String(win.sessionId || ''))
1401
+ && runtime.batchKey === anchor.requestBatchKey(win.messageIds || []);
1402
+ if (sameRequest) {
1403
+ /** 普通记忆可以直接用本请求上下文;实验正文仍要求显式 contextId,不能顺手放行。 */
1404
+ return { ok: true, scope, win, context: runtime.context, held: runtime, experimental: false };
1405
+ }
1222
1406
  return { ok: true, scope, win, context: view.trustedContext(scope, {}), experimental: false };
1223
1407
  }
1224
1408
  /** 先认项目再找本地记录:别的项目的上下文要明确拒绝,不能只报过期。 */
@@ -1371,6 +1555,7 @@ export function apply(ctx, config) {
1371
1555
  scope: value.scope || '',
1372
1556
  revision: value.revision || 0,
1373
1557
  changed: value.changed === true,
1558
+ recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1374
1559
  mode: value.needsScope ? 'needs_scope'
1375
1560
  : value.existingId ? 'duplicate'
1376
1561
  : value.needsResolution ? 'needs_resolution'
@@ -1383,8 +1568,9 @@ export function apply(ctx, config) {
1383
1568
  title: { type: 'string' },
1384
1569
  summary: { type: 'string' },
1385
1570
  when: { type: 'string' },
1571
+ kind: { type: 'string' },
1386
1572
  summaryShortened: { type: 'boolean' },
1387
- fullTextInBody: { type: 'boolean' },
1573
+ fullTextSaved: { type: 'boolean' },
1388
1574
  revision: { type: 'integer' },
1389
1575
  merged: { type: 'boolean' },
1390
1576
  changed: { type: 'boolean' },
@@ -1418,7 +1604,7 @@ export function apply(ctx, config) {
1418
1604
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1419
1605
  : value.changed === false
1420
1606
  ? `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 字,完整原句已存进正文' : ''}`,
1607
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}`,
1422
1608
  }],
1423
1609
  },
1424
1610
  execute: async (args, execution) => {
@@ -1496,8 +1682,9 @@ export function apply(ctx, config) {
1496
1682
  title: saved.record.title,
1497
1683
  summary: saved.record.summary,
1498
1684
  when: saved.record.when || '',
1685
+ kind: saved.record.kind || 'note',
1499
1686
  summaryShortened: Boolean(saved.draft?.summaryShortened),
1500
- fullTextInBody: Boolean(saved.draft?.fullTextInBody),
1687
+ fullTextSaved: Boolean(saved.draft?.fullTextSaved ?? saved.draft?.fullTextInBody),
1501
1688
  revision: saved.record.revision || 1,
1502
1689
  merged: saved.merged,
1503
1690
  changed: !saved.noop,
@@ -1558,7 +1745,10 @@ export function apply(ctx, config) {
1558
1745
  description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1559
1746
  parameters: SEARCH_PARAMS,
1560
1747
  output: {
1561
- presentationMeta: (_args, value) => ({ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [] }),
1748
+ presentationMeta: (_args, value) => ({
1749
+ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [],
1750
+ noTriggerReason: value.noTriggerReason || '',
1751
+ }),
1562
1752
  schema: {
1563
1753
  type: 'object',
1564
1754
  properties: {
@@ -1567,6 +1757,7 @@ export function apply(ctx, config) {
1567
1757
  text: { type: 'string' },
1568
1758
  status: { type: 'string', enum: ['ok', 'budget_exhausted', 'memory_disabled', 'needs_context'] },
1569
1759
  reasonCode: { type: 'string' },
1760
+ noTriggerReason: { type: 'string' },
1570
1761
  refs: {
1571
1762
  type: 'array',
1572
1763
  items: {
@@ -1578,8 +1769,10 @@ export function apply(ctx, config) {
1578
1769
  revision: { type: 'integer' },
1579
1770
  status: { type: 'string' },
1580
1771
  origin: { type: 'string' },
1772
+ kind: { type: 'string' },
1773
+ when: { type: 'string' },
1581
1774
  },
1582
- required: ['id', 'title', 'scope', 'revision', 'status', 'origin'],
1775
+ required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
1583
1776
  additionalProperties: false,
1584
1777
  },
1585
1778
  },
@@ -1618,13 +1811,16 @@ export function apply(ctx, config) {
1618
1811
  }
1619
1812
  const scope = resolved.scope;
1620
1813
  const context = resolved.context;
1814
+ const gateRuntime = resolved.held || win?.runtime
1815
+ || { projectId: scope.key, context, experimental: resolved.experimental === true };
1621
1816
  /** 检索只列当前任务真能用的条目;先取全量候选,过滤后再截断,避免不可用条目挤掉后面的命中。 */
1622
1817
  const limit = args.limit ?? 3;
1623
1818
  const rawHits = search(scope, args.query, 50, {
1624
1819
  context,
1625
1820
  history: args.history === true,
1626
1821
  allowExperimental: resolved.experimental === true,
1627
- }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key);
1822
+ }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key)
1823
+ .filter((item) => item.archived || view.eligibility(item, gateRuntime, 'search').ok);
1628
1824
  const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
1629
1825
  const withheld = rawHits.filter((item) => !item.archived && item.status !== 'ok');
1630
1826
  const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
@@ -1647,6 +1843,7 @@ export function apply(ctx, config) {
1647
1843
  : '有相关的项目记忆,但当前任务明确不适用。',
1648
1844
  status: unresolved ? 'needs_context' : 'ok',
1649
1845
  reasonCode: unresolved ? 'applicability_unresolved' : 'not_applicable',
1846
+ noTriggerReason: unresolved ? '适用条件未确认' : '当前任务不适用',
1650
1847
  refs: [],
1651
1848
  };
1652
1849
  if (win) {
@@ -1659,7 +1856,7 @@ export function apply(ctx, config) {
1659
1856
  }
1660
1857
  /** 没命中是正常结果,不是额度问题;空响应同样记一次本请求开销。 */
1661
1858
  if (!hits.length) {
1662
- const empty = { count: 0, chars: 0, text: '', status: 'ok', refs: [] };
1859
+ const empty = { count: 0, chars: 0, text: '', status: 'ok', reasonCode: 'no_match', noTriggerReason: '没有匹配的记忆', refs: [] };
1663
1860
  if (win) {
1664
1861
  const bytes = jsonBytes(empty);
1665
1862
  if (!affordable(win, bytes)) return { ...empty, status: 'budget_exhausted' };
@@ -1699,6 +1896,7 @@ export function apply(ctx, config) {
1699
1896
  scope: { type: 'string' },
1700
1897
  scopeName: { type: 'string' },
1701
1898
  origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
1899
+ kind: { type: 'string' },
1702
1900
  status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
1703
1901
  body: { type: 'string' },
1704
1902
  links: { type: 'string' },
@@ -1736,6 +1934,7 @@ export function apply(ctx, config) {
1736
1934
  scope: value.scope || '',
1737
1935
  revision: value.revision || 0,
1738
1936
  origin: value.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
1937
+ recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1739
1938
  status: value.status || 'ok',
1740
1939
  }),
1741
1940
  },
@@ -1749,8 +1948,14 @@ export function apply(ctx, config) {
1749
1948
  const allowExperimental = resolved.experimental === true;
1750
1949
  const agent = execution?.agent;
1751
1950
  const win = windowOf(agent);
1951
+ const gateRuntime = resolved.held || win?.runtime
1952
+ || { projectId: scope.key, context, experimental: resolved.experimental === true };
1752
1953
  if (args.view === 'links') {
1753
- const found = readLinks(scope, args.id, { context, allowExperimental });
1954
+ const source = locate(scope, args.id);
1955
+ if (source && !view.eligibility(source.record, gateRuntime, 'read').ok) {
1956
+ return { id: '', scope: '', status: 'needs_context', links: '' };
1957
+ }
1958
+ const found = readLinks(scope, args.id, { context, allowExperimental, runtime: gateRuntime, now: Date.now() });
1754
1959
  if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1755
1960
  if (found.status !== 'ok') {
1756
1961
  const withheld = { id: '', scope: '', status: found.status, links: '' };
@@ -1778,20 +1983,8 @@ export function apply(ctx, config) {
1778
1983
  }
1779
1984
  const record = readEntry(scope, args.id, { context, allowExperimental });
1780
1985
  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
- }
1986
+ const gate = view.eligibility(record, gateRuntime, 'read');
1987
+ if (!gate.ok) return { id: '', scope: record.scope, status: gate.status, body: '' };
1795
1988
  /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
1796
1989
  if (record.status !== 'ok') {
1797
1990
  return { id: '', scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
@@ -1806,6 +1999,7 @@ export function apply(ctx, config) {
1806
1999
  scope: record.scope,
1807
2000
  scopeName,
1808
2001
  status: record.status,
2002
+ kind: record.kind || 'note',
1809
2003
  body,
1810
2004
  };
1811
2005
  if (win) {
@@ -1963,7 +2157,6 @@ export function apply(ctx, config) {
1963
2157
  });
1964
2158
 
1965
2159
  if (typeof ctx.on === 'function') ctx.on('agent/pre-step', async (payload, next) => {
1966
- const startedAt = Date.now();
1967
2160
  const agent = payload?.agent;
1968
2161
  syncMemoryTools(agent);
1969
2162
  let decision;
@@ -1973,6 +2166,8 @@ export function apply(ctx, config) {
1973
2166
  clearPrepared(agent);
1974
2167
  throw error;
1975
2168
  }
2169
+ /** 宿主等待不算本地召回时间:额度从拿到这一步的开始算。 */
2170
+ const startedAt = Date.now();
1976
2171
  try {
1977
2172
  if (!agent || decision?.kind !== 'enter') {
1978
2173
  clearPrepared(agent);
@@ -2018,7 +2213,8 @@ export function apply(ctx, config) {
2018
2213
  }
2019
2214
  if (fingerprint !== previous.fingerprint) {
2020
2215
  const win = windowOf(agent);
2021
- const snapshot = projectRecall(scope, previous.query, view.trustedContext(scope, {}), win);
2216
+ if (win) win.autoBudget = emptyAutoBudget();
2217
+ const snapshot = projectRecall(scope, previous.query, win && win.runtime ? win.runtime.context : view.trustedContext(scope, {}), win, win && win.runtime ? win.runtime : null);
2022
2218
  let experimentText = '';
2023
2219
  if (win && win.experiment) {
2024
2220
  experimentText = renderExperiment(scope, win.experiment.decision, win.experiment.context, win.experiment, win);
@@ -2042,15 +2238,16 @@ export function apply(ctx, config) {
2042
2238
  /** 工具模式也要开额度窗口:只是不自动注入,工具调用同样受每请求预算约束。 */
2043
2239
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
2044
2240
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2241
+ const budgetBefore = win ? {
2242
+ bytes: win.bytes, bodies: win.bodies, reads: win.reads,
2243
+ provided: new Map(win.provided), readBodies: new Map(win.readBodies),
2244
+ recallHeader: win.recallHeader, experimentBytes: win.experimentBytes,
2245
+ autoBudget: win.autoBudget ? JSON.parse(JSON.stringify(win.autoBudget)) : null,
2246
+ } : null;
2045
2247
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2046
2248
  const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2047
2249
  if (win && query) win.query = query;
2048
2250
 
2049
- if (recallMode() !== 'adaptive') {
2050
- clearPrepared(agent);
2051
- return decision;
2052
- }
2053
-
2054
2251
  if (!query) {
2055
2252
  clearPrepared(agent);
2056
2253
  return decision;
@@ -2065,29 +2262,60 @@ export function apply(ctx, config) {
2065
2262
  clearPrepared(agent);
2066
2263
  return decision;
2067
2264
  }
2265
+ const batchKey = anchor.requestBatchKey(ids);
2266
+ /**
2267
+ * 工具模式也必须先有本请求上下文:memory_search/read/links 靠同一个 contextId 判项目和任务,
2268
+ * 不能等到自动召回分支才建,否则工具模式永远拿不到可核对的任务。
2269
+ */
2270
+ const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
2271
+ if (win && runtime) win.runtime = runtime.held;
2272
+ if (recallMode() !== 'adaptive') {
2273
+ clearPrepared(agent);
2274
+ return decision;
2275
+ }
2068
2276
  const context = view.trustedContext(scope, {});
2069
2277
  const fingerprint = fingerprintOf(scope);
2070
2278
  const controlEpoch = controlStatus(scope).epoch || '';
2071
- const batchKey = anchor.requestBatchKey(ids);
2072
2279
  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)
2280
+ /** 任务上下文独立于自进化开关:普通记忆按当前任务判适用,实验规则再叠一层可用性。 */
2281
+ const auto = emptyAutoBudget();
2282
+ if (win) win.autoBudget = auto;
2283
+ const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2284
+ const recalledContext = runtime ? runtime.held.context : context;
2285
+ const remembered = runtime && runtime.enabled
2286
+ ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
2076
2287
  : null;
2077
- const snapshot = projectRecall(scope, query, context, win);
2078
- if (generation !== generations.get(agent)) return decision;
2288
+ const snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2289
+ /** 没发布出去的投影要退还额度:不能因为一次超时把本请求的召回额度耗掉。 */
2290
+ const rollbackBudget = () => {
2291
+ if (!win || !budgetBefore) return;
2292
+ win.bytes = budgetBefore.bytes;
2293
+ win.bodies = budgetBefore.bodies;
2294
+ win.reads = budgetBefore.reads;
2295
+ win.provided = budgetBefore.provided;
2296
+ win.readBodies = budgetBefore.readBodies;
2297
+ win.recallHeader = budgetBefore.recallHeader;
2298
+ win.experimentBytes = budgetBefore.experimentBytes;
2299
+ win.autoBudget = budgetBefore.autoBudget || emptyAutoBudget();
2300
+ win.experiment = null;
2301
+ win.contexts.clear();
2302
+ };
2303
+ if (generation !== generations.get(agent)) { rollbackBudget(); return decision; }
2079
2304
  /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
2080
2305
  if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
2306
+ rollbackBudget();
2081
2307
  clearPrepared(agent);
2082
2308
  return decision;
2083
2309
  }
2084
2310
  /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
2085
2311
  if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
2312
+ rollbackBudget();
2086
2313
  clearPrepared(agent);
2087
2314
  return decision;
2088
2315
  }
2089
2316
  /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2090
2317
  if (!snapshot && !experiment) {
2318
+ rollbackBudget();
2091
2319
  clearPrepared(agent);
2092
2320
  return decision;
2093
2321
  }
@@ -2196,6 +2424,13 @@ export function apply(ctx, config) {
2196
2424
  const route = routeForSession(session);
2197
2425
  if (!route) return;
2198
2426
  const candidate = pending.candidate;
2427
+ /** 候选进入整理前再走一次统一准入:项目/作品身份、作用域与反馈类型都对上才花配额。 */
2428
+ const admission = learning.admissionOf(candidate, {
2429
+ projectId: scope.key,
2430
+ sessionId: candidate.sessionId,
2431
+ anchor: pending.held?.anchor || null,
2432
+ }, 'collect');
2433
+ if (!admission || admission.ok !== true) return;
2199
2434
  const operationId = `learn_${crypto.createHash('sha256')
2200
2435
  .update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
2201
2436
  .digest('hex').slice(0, 16)}`;
@@ -2254,6 +2489,12 @@ export function apply(ctx, config) {
2254
2489
  }
2255
2490
  const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
2256
2491
  if (!parsed.ok) throw new Error(parsed.reason);
2492
+ /** 用户已经把要求说成长期规则,模型仍返回空时至少留一条规则,不让这条反馈无声消失。 */
2493
+ const changes = parsed.changes.length
2494
+ ? parsed.changes
2495
+ : (candidate.cue?.explicitLongTerm && candidate.cue?.type === 'spec_change'
2496
+ ? [learning.pendingChangeFrom(candidate)].filter(Boolean)
2497
+ : []);
2257
2498
  const commit = commitLearning(scope, {
2258
2499
  operationId,
2259
2500
  epoch: begin.epoch,
@@ -2263,8 +2504,9 @@ export function apply(ctx, config) {
2263
2504
  sessionId: candidate.sessionId,
2264
2505
  messageIds: candidate.messageIds,
2265
2506
  feedbackText: candidate.text,
2507
+ sourceExcerpt: candidate.sourceExcerpt || learning.sourceExcerptOf(candidate.text),
2266
2508
  dimension: candidate.cue?.dimension || '',
2267
- changes: parsed.changes,
2509
+ changes,
2268
2510
  preparedTargets,
2269
2511
  usage: learning.usageOf(chunks),
2270
2512
  explicitLongTerm: Boolean(candidate.cue.explicitLongTerm),