@modusensus/dsh-mneme 0.8.4 → 0.8.6

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.
Files changed (75) hide show
  1. package/README.md +45 -8
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +106 -5
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +128 -12
  9. package/lib/dream.js +46 -11
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/index.js +65 -3
  13. package/lib/inject.js +91 -15
  14. package/lib/lang.js +6 -0
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/organize.js +450 -0
  17. package/lib/recall-stats.js +23 -1
  18. package/lib/runtime/layout.js +30 -0
  19. package/lib/runtime/loader.js +4 -1
  20. package/lib/runtime/verify.js +19 -6
  21. package/lib/search/adaptive.js +38 -0
  22. package/lib/service.js +165 -12
  23. package/lib/settings.js +35 -4
  24. package/lib/store.js +126 -8
  25. package/lib/summarize.js +253 -8
  26. package/lib/tools.js +136 -8
  27. package/package.json +6 -6
  28. package/runtime-manifest.json +104 -174
  29. package/scripts/build-runtime-manifest.mjs +8 -1
  30. package/scripts/sync-test-badge.mjs +18 -12
  31. package/src/api-standalone.js +11 -0
  32. package/src/api.js +21 -0
  33. package/src/config.js +106 -5
  34. package/src/document.js +221 -0
  35. package/src/dream/sleep.js +128 -12
  36. package/src/dream.js +46 -11
  37. package/src/guide.js +44 -0
  38. package/src/heat.js +2 -0
  39. package/src/index.js +65 -3
  40. package/src/inject.js +91 -15
  41. package/src/lang.js +6 -0
  42. package/src/local-embedder.js +44 -6
  43. package/src/organize.js +450 -0
  44. package/src/recall-stats.js +23 -1
  45. package/src/runtime/layout.js +30 -0
  46. package/src/runtime/loader.js +4 -1
  47. package/src/runtime/verify.js +19 -6
  48. package/src/search/adaptive.js +38 -0
  49. package/src/service.js +165 -12
  50. package/src/settings.js +35 -4
  51. package/src/store.js +126 -8
  52. package/src/summarize.js +253 -8
  53. package/src/tools.js +136 -8
  54. package/test/adaptive-inject-budget.test.js +85 -0
  55. package/test/api.test.js +66 -3
  56. package/test/client.test.js +63 -2
  57. package/test/document.test.js +523 -0
  58. package/test/dream.test.js +51 -0
  59. package/test/inject-content-cap.test.js +5 -3
  60. package/test/inject-pools.test.js +170 -0
  61. package/test/inject-recall.test.js +105 -0
  62. package/test/inject.test.js +35 -1
  63. package/test/lastrunat-persist.test.js +101 -0
  64. package/test/llm-audit.test.js +247 -0
  65. package/test/local-embedder.test.js +45 -2
  66. package/test/organize.test.js +418 -0
  67. package/test/recall-stats.test.js +7 -1
  68. package/test/runtime-loader.test.js +18 -0
  69. package/test/runtime-manifest.test.js +2 -1
  70. package/test/runtime-verify.test.js +65 -0
  71. package/test/sleep.test.js +154 -0
  72. package/test/store.test.js +13 -0
  73. package/test/summarize.test.js +407 -3
  74. package/test/tools-dto-schema-matrix.test.js +325 -0
  75. package/test/tools.test.js +36 -3
package/src/store.js CHANGED
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS memories (
18
18
  last_accessed_at TEXT,
19
19
  _full_content TEXT,
20
20
  evidence TEXT,
21
+ doc_path TEXT,
21
22
  created_at TEXT NOT NULL,
22
23
  updated_at TEXT NOT NULL
23
24
  );
@@ -47,7 +48,7 @@ CREATE TABLE IF NOT EXISTS dream_runs (
47
48
  summary_stored INTEGER NOT NULL DEFAULT 0,
48
49
  receipt TEXT NOT NULL,
49
50
  policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
50
- run_type TEXT NOT NULL DEFAULT 'auto', -- auto | sleep:睡眠周期的审计区分
51
+ run_type TEXT NOT NULL DEFAULT 'auto', -- auto | sleep | organize:周期审计的类别区分
51
52
  skipped TEXT -- JSON: degraded 轮被跳过的逐条明细(index/action/ids/error)
52
53
  );
53
54
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
@@ -196,6 +197,14 @@ CREATE TABLE IF NOT EXISTS llm_audit_logs (
196
197
  CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
197
198
  CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
198
199
 
200
+ -- autoSummarize 的增量蒸馏游标:按 session.id 持久化最近一次成功消费的事件序。
201
+ -- 游标是蒸馏窗口的恢复事实,不与 user_settings 或记忆内容混用。
202
+ CREATE TABLE IF NOT EXISTS distill_cursors (
203
+ session_id TEXT PRIMARY KEY,
204
+ last_seq INTEGER NOT NULL,
205
+ updated_at TEXT NOT NULL
206
+ );
207
+
199
208
  -- entity gene (v0.3.0): named entities mentioned across memories, with
200
209
  -- time-boxed attributes (valid_from → valid_until) and typed relations.
201
210
  -- Attributes follow the snapshot style: saveAttr invalidates the previous
@@ -268,7 +277,10 @@ CREATE TABLE IF NOT EXISTS mirror_state (
268
277
  // Exported for API-layer type validation (standalone API POST /memories and
269
278
  // the /status byType breakdown); the set itself stays the single source of
270
279
  // truth for what store.save accepts.
271
- export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "rejected_solution", "pitfall", "constraint"]);
280
+ export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "rejected_solution", "pitfall", "constraint",
281
+ // #230:agent 产长文档的指针行(摘要 + doc_path + evidence)。铸造口唯一
282
+ // (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。
283
+ "document"]);
272
284
 
273
285
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
274
286
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.
@@ -400,6 +412,7 @@ function toRow(row) {
400
412
  source: row.source ?? undefined,
401
413
  content_history: parseJsonArray(row.content_history),
402
414
  evidence: parseJsonArray(row.evidence),
415
+ doc_path: row.doc_path ?? undefined,
403
416
  quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
404
417
  epistemic_status: row.epistemic_status ?? "subjective",
405
418
  agent_scope: row.agent_scope ?? undefined,
@@ -674,6 +687,8 @@ export function createStore(path) {
674
687
  // 叙述条证据链(#164 对齐):[{memory_id, op, at}] JSON 数组——叙述/模式类
675
688
  // 记忆回链其支撑原子记忆,写入前与候选集求交防模型捏造。
676
689
  addColumn("memories", "evidence", "ALTER TABLE memories ADD COLUMN evidence TEXT");
690
+ // #230:document 指针行的文件定位。只有 registerDocument 写它,普通行恒 NULL。
691
+ addColumn("memories", "doc_path", "ALTER TABLE memories ADD COLUMN doc_path TEXT");
677
692
  addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
678
693
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
679
694
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
@@ -782,6 +797,37 @@ export function createStore(path) {
782
797
  return ts;
783
798
  }
784
799
 
800
+ /** 返回指定会话持久化的 autoSummarize 游标;不存在时返回 undefined。 */
801
+ function getDistillCursor(sessionId) {
802
+ if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
803
+ const row = db.prepare(
804
+ "SELECT session_id, last_seq, updated_at FROM distill_cursors WHERE session_id = ?"
805
+ ).get(sessionId);
806
+ return row
807
+ ? { session_id: row.session_id, last_seq: Number(row.last_seq), updated_at: row.updated_at }
808
+ : undefined;
809
+ }
810
+
811
+ /**
812
+ * 单调持久化会话游标。调用方若同时写入记忆,必须放在外层 SQLite 事务内。
813
+ */
814
+ function setDistillCursor(sessionId, lastSeq) {
815
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
816
+ throw new TypeError("setDistillCursor: sessionId must be a non-empty string");
817
+ }
818
+ if (!Number.isSafeInteger(lastSeq)) {
819
+ throw new TypeError("setDistillCursor: lastSeq must be a safe integer");
820
+ }
821
+ db.prepare(`
822
+ INSERT INTO distill_cursors (session_id, last_seq, updated_at)
823
+ VALUES (?, ?, ?)
824
+ ON CONFLICT(session_id) DO UPDATE SET
825
+ last_seq = MAX(distill_cursors.last_seq, excluded.last_seq),
826
+ updated_at = excluded.updated_at
827
+ `).run(sessionId, lastSeq, nowIso());
828
+ return getDistillCursor(sessionId);
829
+ }
830
+
785
831
  function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
786
832
  const clauses = [];
787
833
  const params = [];
@@ -863,7 +909,28 @@ export function createStore(path) {
863
909
  return toRow(row);
864
910
  }
865
911
 
912
+ // #230 写入权分离(存储层唯一铸造口,CodeRabbit 复核最终形态):document
913
+ // 行只能经 saveDocument 铸造,通用 save 整类拒绝。doc_path 是任意调用方
914
+ // 都能捏造的字符串——「必带 doc_path」挡不住绕过注册校验(flag 闸 / 文件
915
+ // 存在 / 路径归一化 / evidence 可见性 / scope 匹配 / supersede 探测)的直
916
+ // 铸,必须整类拒绝;受控通道是独立方法而不是隐藏旗标(旗标可被载荷携带,
917
+ // 方法名在 DI 合同里可审计)。
866
918
  function save(memory) {
919
+ if (memory?.type === "document") {
920
+ throw new Error("document rows are minted only via store.saveDocument (registerDocument)");
921
+ }
922
+ return insertMemoryRow(memory);
923
+ }
924
+
925
+ function saveDocument(memory) {
926
+ // 指针行结构不变量:doc_path 是 document 的存在依据(无指针 = 死行)。
927
+ if (!(typeof memory?.doc_path === "string" && memory.doc_path.trim())) {
928
+ throw new Error("document rows are pointer rows: doc_path is required");
929
+ }
930
+ return insertMemoryRow(memory);
931
+ }
932
+
933
+ function insertMemoryRow(memory) {
867
934
  const id = memory.id ?? randomUUID();
868
935
  const type = memory.type;
869
936
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
@@ -874,6 +941,7 @@ export function createStore(path) {
874
941
  const tags = JSON.stringify(memory.tags ?? []);
875
942
  const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
876
943
  const evidence = Array.isArray(memory.evidence) ? JSON.stringify(memory.evidence) : null;
944
+ const docPath = typeof memory.doc_path === "string" && memory.doc_path.trim() ? memory.doc_path : null;
877
945
  const embedding = Array.isArray(memory.embedding) && memory.embedding.length
878
946
  ? JSON.stringify(memory.embedding)
879
947
  : null;
@@ -884,8 +952,8 @@ export function createStore(path) {
884
952
  : inferEpistemicStatus(memory);
885
953
  runAtomically(() => {
886
954
  db.prepare(
887
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, created_at, updated_at)
888
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
955
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, created_at, updated_at)
956
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
889
957
  ).run(
890
958
  id,
891
959
  type,
@@ -907,6 +975,7 @@ export function createStore(path) {
907
975
  normalizeScopeText(memory.sensitivity),
908
976
  normalizeOccurredAt(memory.occurred_at),
909
977
  evidence,
978
+ docPath,
910
979
  now,
911
980
  now
912
981
  );
@@ -924,6 +993,12 @@ export function createStore(path) {
924
993
  if (!existing) throw new Error(`memory not found: ${id}`);
925
994
  const type = patch.type ?? existing.type;
926
995
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
996
+ // #230:type 不许改入/改出 document——铸造与退役都只经 registerDocument
997
+ // (摘要修复走同 type 的 content/title 更新,doc_path 不在本 UPDATE 的
998
+ // SET 里,天然保持不变)。
999
+ if ((existing.type === "document") !== (type === "document")) {
1000
+ throw new Error("memory type cannot be changed to or from 'document' (registerDocument is the only mint path)");
1001
+ }
927
1002
  if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
928
1003
  throw new Error("tags must be an array");
929
1004
  }
@@ -1015,6 +1090,11 @@ export function createStore(path) {
1015
1090
  if (!existing) throw new Error(`memory not found: ${id}`);
1016
1091
  const type = patch.type ?? existing.type;
1017
1092
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
1093
+ // 同 update:document 类型转换在这里同样封死(CAS 路径绕过 updateMemory
1094
+ // 的 service 守卫,必须在存储层兜住)。
1095
+ if ((existing.type === "document") !== (type === "document")) {
1096
+ throw new Error("memory type cannot be changed to or from 'document' (registerDocument is the only mint path)");
1097
+ }
1018
1098
  if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
1019
1099
  throw new Error("tags must be an array");
1020
1100
  }
@@ -1135,11 +1215,15 @@ export function createStore(path) {
1135
1215
  // Live memories that have not been touched since `cutMs` (never-touched ones
1136
1216
  // fall back to created_at). Ordered by last access ascending — the coldest
1137
1217
  // first. Used by sleep phase 2 to pick archival-demotion candidates.
1218
+ // #230: document pointer rows are excluded — they are heat-immune by design
1219
+ // (λ=0) and their "unread" state is normal (full text lives outside the DB),
1220
+ // so the cold scan must never demote/archive them for lacking access.
1138
1221
  function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
1139
1222
  const cutIso = new Date(cutMs).toISOString();
1140
1223
  const rows = db.prepare(
1141
1224
  `SELECT * FROM memories
1142
1225
  WHERE forgotten = 0 AND archived = 0
1226
+ AND type <> 'document'
1143
1227
  AND (last_accessed_at IS NULL OR last_accessed_at < ?)
1144
1228
  ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
1145
1229
  LIMIT ?`
@@ -1147,13 +1231,20 @@ export function createStore(path) {
1147
1231
  return rows.map(toRow);
1148
1232
  }
1149
1233
 
1150
- function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
1234
+ function list({ type, excludeTypes = null, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
1151
1235
  const clauses = [];
1152
1236
  const params = [];
1153
1237
  if (type) {
1154
1238
  clauses.push("type = ?");
1155
1239
  params.push(type);
1156
1240
  }
1241
+ // #230:整类排除必须在 LIMIT 之前做——sleep 的模式扫描池(limit 200 +
1242
+ // sleepPatternMinMemories 门槛)若先截断后过滤,document 行一多就会把
1243
+ // 普通记忆挤出窗口,池子被饿空。
1244
+ if (Array.isArray(excludeTypes) && excludeTypes.length) {
1245
+ clauses.push(`type NOT IN (${excludeTypes.map(() => "?").join(", ")})`);
1246
+ params.push(...excludeTypes);
1247
+ }
1157
1248
  // Optional server-side filters: importance floor and exact source match.
1158
1249
  // Both stay out of the query when unset so existing callers are unaffected.
1159
1250
  if (minImportance != null) {
@@ -1227,15 +1318,20 @@ export function createStore(path) {
1227
1318
  "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
1228
1319
  );
1229
1320
  }
1321
+ // limit == null = 无界(#230 注册器的精确 supersede 全量扫描用——同路径/
1322
+ // 同标题判定不允许窗口截断)。其余调用传数字,走 sanitizePage 默认档。
1323
+ const unbounded = limit == null;
1230
1324
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1231
1325
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1232
1326
  // "chrono" is pure newest-first — the stable order paged browsing (month
1233
1327
  // tree, infinite scroll) needs; importance ordering would interleave
1234
1328
  // months across pages.
1235
1329
  const orderBy = order === "chrono" ? "updated_at DESC, id DESC" : "importance DESC, updated_at DESC, id";
1236
- const rows = db.prepare(
1237
- `SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1238
- ).all(...params, lim, off);
1330
+ const rows = unbounded
1331
+ ? db.prepare(`SELECT * FROM memories ${where} ORDER BY ${orderBy}`).all(...params)
1332
+ : db.prepare(
1333
+ `SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1334
+ ).all(...params, lim, off);
1239
1335
  return rows.map(toRow);
1240
1336
  }
1241
1337
 
@@ -1436,6 +1532,22 @@ export function createStore(path) {
1436
1532
  return rows.map(toDreamRun);
1437
1533
  }
1438
1534
 
1535
+ // Issue #89:上次实际开跑时刻(epoch ms),从审计表恢复——调度器的 lastRunAt
1536
+ // 只活在内存里,进程重启即归零,闸门对新实例放行 → 重启后立刻连发。审计表
1537
+ // 本来就逐 run 落库(failed/degraded 也算 run),直接读它就是事实源,零迁移。
1538
+ function lastDreamRunAt(runType = null) {
1539
+ const row = runType
1540
+ ? db.prepare(
1541
+ "SELECT created_at FROM dream_runs WHERE run_type = ? ORDER BY created_at DESC, id LIMIT 1"
1542
+ ).get(runType)
1543
+ : db.prepare(
1544
+ "SELECT created_at FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1545
+ ).get();
1546
+ if (!row?.created_at) return 0;
1547
+ const ms = Date.parse(row.created_at);
1548
+ return Number.isFinite(ms) ? ms : 0;
1549
+ }
1550
+
1439
1551
  /**
1440
1552
  * Latest ruling-rule version seen on the audit trail. policy_epoch is a config
1441
1553
  * value stamped onto each run by the caller; reading the newest row's epoch
@@ -2368,6 +2480,9 @@ export function createStore(path) {
2368
2480
  count,
2369
2481
  getById,
2370
2482
  save,
2483
+ // document 唯一铸造口(#230 写入权分离):registerDocument 专用,通用
2484
+ // save/update/CAS 均拒绝 document 创建或类型转换。
2485
+ saveDocument,
2371
2486
  update,
2372
2487
  compareAndUpdate,
2373
2488
  remove,
@@ -2387,6 +2502,7 @@ export function createStore(path) {
2387
2502
  needsEmbedding,
2388
2503
  searchVector,
2389
2504
  saveDreamRun,
2505
+ lastDreamRunAt,
2390
2506
  getDreamRun,
2391
2507
  listDreamRuns,
2392
2508
  getLatestPolicyEpoch,
@@ -2401,6 +2517,8 @@ export function createStore(path) {
2401
2517
  getRecallEval,
2402
2518
  listRecallEvals,
2403
2519
  saveLlmAudit,
2520
+ getDistillCursor,
2521
+ setDistillCursor,
2404
2522
  listLlmAudits,
2405
2523
  countLlmAudits,
2406
2524
  getLlmAuditStats,
package/src/summarize.js CHANGED
@@ -256,13 +256,136 @@ function enqueueDistill(task, intervalMs = 0) {
256
256
  return run;
257
257
  }
258
258
 
259
- export function createSummarizer(ctx, service, config) {
259
+ // Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程判断
260
+ // 不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
261
+ // spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
262
+ // "09:00-18:00" 每天 09:00-18:00
263
+ // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
264
+ // "sat,sun 23:00-06:00" 周末跨零点段
265
+ // 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
266
+ // 绝不该因为写错格式把蒸馏停掉。
267
+ const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
268
+
269
+ function parseDayToken(token) {
270
+ const days = new Set();
271
+ const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
272
+ for (const piece of token.split(",")) {
273
+ const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
274
+ if (!matched) return null;
275
+ const from = normalize(matched[1]);
276
+ const to = matched[2] === undefined ? from : normalize(matched[2]);
277
+ if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
278
+ // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
279
+ for (let day = from; ; day = (day % 7) + 1) {
280
+ days.add(day);
281
+ if (day === to) break;
282
+ }
283
+ }
284
+ return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
285
+ }
286
+
287
+ /** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
288
+ function isoDay(date) {
289
+ const day = date.getDay();
290
+ return day === 0 ? 7 : day;
291
+ }
292
+
293
+ export function parsePeakSpec(spec) {
294
+ if (typeof spec !== "string" || spec.trim() === "") return null;
295
+ let rest = spec.trim();
296
+ let days = null;
297
+ // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
298
+ // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
299
+ // 不做猜测——宁可不省,也不能误停。
300
+ const sep = rest.search(/\s/);
301
+ if (sep > 0) {
302
+ const head = rest.slice(0, sep);
303
+ if (!head.includes(":")) {
304
+ days = parseDayToken(head);
305
+ if (days === null) return null;
306
+ rest = rest.slice(sep).trim();
307
+ }
308
+ }
309
+ const windows = [];
310
+ for (const part of rest.split(",")) {
311
+ const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
312
+ if (!matched) return null;
313
+ const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
314
+ if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
315
+ const start = sh * 60 + sm;
316
+ const end = eh * 60 + em;
317
+ if (start === end) return null;
318
+ windows.push({ start, end });
319
+ }
320
+ return windows.length > 0 ? { days, windows } : null;
321
+ }
322
+
323
+ /**
324
+ * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
325
+ * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
326
+ */
327
+ export function isInPeakWindow(date, spec) {
328
+ const parsed = parsePeakSpec(spec);
329
+ if (!parsed) return false;
330
+ const minutes = date.getHours() * 60 + date.getMinutes();
331
+ const today = isoDay(date);
332
+ const yesterday = today === 1 ? 7 : today - 1;
333
+ const allowed = (day) => !parsed.days || parsed.days.includes(day);
334
+ return parsed.windows.some(({ start, end }) => {
335
+ if (start < end) return minutes >= start && minutes < end && allowed(today);
336
+ return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
337
+ });
338
+ }
339
+
340
+ /**
341
+ * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
342
+ * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
343
+ */
344
+ export function nextOffPeakAt(date, spec) {
345
+ const parsed = parsePeakSpec(spec);
346
+ if (!parsed) return null;
347
+ const minutes = date.getHours() * 60 + date.getMinutes();
348
+ const today = isoDay(date);
349
+ const yesterday = today === 1 ? 7 : today - 1;
350
+ const allowed = (day) => !parsed.days || parsed.days.includes(day);
351
+ let bestDelta = null;
352
+ const consider = (delta) => {
353
+ if (bestDelta === null || delta < bestDelta) bestDelta = delta;
354
+ };
355
+ for (const { start, end } of parsed.windows) {
356
+ if (start < end) {
357
+ if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
358
+ continue;
359
+ }
360
+ if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
361
+ else if (minutes < end && allowed(yesterday)) consider(end - minutes);
362
+ }
363
+ if (bestDelta === null) return null;
364
+ const at = new Date(date.getTime());
365
+ at.setSeconds(0, 0);
366
+ at.setMinutes(at.getMinutes() + bestDelta);
367
+ return at;
368
+ }
369
+
370
+ export function createSummarizer(ctx, service, config, deps = {}) {
260
371
  if (!config.autoSummarize) return { dispose: () => {} };
261
372
 
373
+ // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
374
+ // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
375
+ const now = typeof deps.now === "function" ? deps.now : () => Date.now();
376
+ const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
377
+ const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
378
+
262
379
  const inFlight = new Map();
263
380
  // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
264
381
  // 同生命周期,dispose 时一并清空。
265
382
  const lastRunAt = new Map();
383
+ // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
384
+ // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
385
+ const runsUsed = new Map();
386
+ // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
387
+ // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
388
+ const deferredRuns = new Map();
266
389
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
267
390
  const lastDistilledSeq = new Map();
268
391
  let disposed = false;
@@ -282,7 +405,52 @@ export function createSummarizer(ctx, service, config) {
282
405
  }
283
406
  }
284
407
 
285
- async function summarize(session, triggerEvent) {
408
+ function persistCursor(sessionId, nextSeq) {
409
+ if (!Number.isFinite(nextSeq)) return;
410
+ if (typeof service.setDistillCursor !== "function") {
411
+ // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
412
+ // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
413
+ // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
414
+ // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
415
+ ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
416
+ return;
417
+ }
418
+ service.setDistillCursor(sessionId, nextSeq);
419
+ }
420
+
421
+ function commitCursor(sessionId, nextSeq) {
422
+ persistCursor(sessionId, nextSeq);
423
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
424
+ }
425
+
426
+ /**
427
+ * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
428
+ * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
429
+ * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
430
+ */
431
+ function scheduleDeferredRun(session) {
432
+ if (disposed || deferredRuns.has(session.id)) return;
433
+ const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
434
+ if (!at) return;
435
+ const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
436
+ let delay = Math.max(0, at.getTime() - now());
437
+ const capped = maxDeferMs > 0 && delay > maxDeferMs;
438
+ if (capped) delay = maxDeferMs;
439
+ const timer = setTimer(() => {
440
+ deferredRuns.delete(session.id);
441
+ if (disposed) return;
442
+ // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
443
+ // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
444
+ summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
445
+ if (disposed || error?.name === "AbortError") return;
446
+ ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
447
+ });
448
+ }, delay);
449
+ timer?.unref?.();
450
+ deferredRuns.set(session.id, timer);
451
+ }
452
+
453
+ async function summarize(session, triggerEvent, opts = {}) {
286
454
  if (disposed || inFlight.has(session.id)) return;
287
455
 
288
456
  const header = session.requestHeader?.()?.config;
@@ -308,6 +476,37 @@ export function createSummarizer(ctx, service, config) {
308
476
  });
309
477
  return;
310
478
  }
479
+ // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
480
+ // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
481
+ // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
482
+ // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
483
+ // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
484
+ // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
485
+ // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
486
+ // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
487
+ // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
488
+ // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
489
+ if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
490
+ writeAudit({
491
+ timestamp: new Date(now()).toISOString(),
492
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
493
+ status: "skipped",
494
+ error_message: "peak-hours"
495
+ });
496
+ scheduleDeferredRun(session);
497
+ return;
498
+ }
499
+ const prevRuns = runsUsed.get(session.id);
500
+ const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
501
+ if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
502
+ writeAudit({
503
+ timestamp: new Date().toISOString(),
504
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
505
+ status: "skipped",
506
+ error_message: "max-runs-per-session"
507
+ });
508
+ return;
509
+ }
311
510
  const controller = new AbortController();
312
511
  inFlight.set(session.id, controller);
313
512
  // audit state for the compression call. null = no audit for this run
@@ -319,7 +518,10 @@ export function createSummarizer(ctx, service, config) {
319
518
  let abortedRun = false;
320
519
  try {
321
520
  if (!route) return;
322
- const previousSeq = lastDistilledSeq.get(session.id);
521
+ const persistedCursor = typeof service.getDistillCursor === "function"
522
+ ? service.getDistillCursor(session.id)
523
+ : undefined;
524
+ const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
323
525
  const triggerSeq = eventSeq(triggerEvent);
324
526
  // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
325
527
  // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
@@ -338,13 +540,37 @@ export function createSummarizer(ctx, service, config) {
338
540
  if (!collected.messages.length) {
339
541
  // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
340
542
  // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
341
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
543
+ commitCursor(session.id, nextSeq);
342
544
  return;
343
545
  }
344
546
  const messages = collected.messages;
345
- // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔;失败路径仍按原语义
346
- // 占用,aborted 则由 finally 回滚。
547
+ // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
548
+ // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
549
+ // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
550
+ // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
551
+ // 决定下一级规则的依据。
552
+ const minWindowChars = config.summarizeMinWindowChars ?? 0;
553
+ if (minWindowChars > 0) {
554
+ const distillChars = messages.reduce(
555
+ (total, message) => total + (Array.isArray(message.content) ? message.content : [])
556
+ .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
557
+ 0
558
+ );
559
+ if (distillChars < minWindowChars) {
560
+ commitCursor(session.id, nextSeq);
561
+ writeAudit({
562
+ timestamp: new Date().toISOString(),
563
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
564
+ status: "skipped",
565
+ error_message: "window-too-small"
566
+ });
567
+ return;
568
+ }
569
+ }
570
+ // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
571
+ // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
347
572
  lastRunAt.set(session.id, Date.now());
573
+ runsUsed.set(session.id, (prevRuns ?? 0) + 1);
348
574
 
349
575
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
350
576
  audit = {
@@ -385,8 +611,11 @@ export function createSummarizer(ctx, service, config) {
385
611
  text += chunk.text ?? chunk.delta ?? "";
386
612
  }
387
613
  if (chunk.type === "usage" && audit) {
388
- const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
389
- const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
614
+ // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
615
+ // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
616
+ const usage = chunk.usage ?? chunk;
617
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
618
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
390
619
  if (Number.isFinite(i)) audit.inputTokens = i;
391
620
  if (Number.isFinite(o)) audit.outputTokens = o;
392
621
  }
@@ -504,7 +733,11 @@ export function createSummarizer(ctx, service, config) {
504
733
  ...(dup ? { _mergeInto: dup.memory.id } : {})
505
734
  });
506
735
  }
736
+ persistCursor(session.id, nextSeq);
507
737
  });
738
+ } else {
739
+ // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
740
+ persistCursor(session.id, nextSeq);
508
741
  }
509
742
  if (audit && (capped > 0 || deduped > 0)) {
510
743
  audit.metadata = {
@@ -516,12 +749,22 @@ export function createSummarizer(ctx, service, config) {
516
749
  // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
517
750
  // 都会在此之前退出,从而保留窗口供下一次重试。
518
751
  if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
752
+ } catch (error) {
753
+ if (audit) {
754
+ audit.status = "error";
755
+ audit.errorMessage = String(error?.message ?? error);
756
+ }
757
+ throw error;
519
758
  } finally {
520
759
  // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
521
760
  // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
522
761
  if (abortedRun || controller.signal.aborted) {
523
762
  if (prevClaim === undefined) lastRunAt.delete(session.id);
524
763
  else lastRunAt.set(session.id, prevClaim);
764
+ // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
765
+ // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
766
+ if (prevRuns === undefined) runsUsed.delete(session.id);
767
+ else runsUsed.set(session.id, prevRuns);
525
768
  }
526
769
  if (audit) {
527
770
  try {
@@ -566,6 +809,8 @@ export function createSummarizer(ctx, service, config) {
566
809
  if (disposed) return;
567
810
  disposed = true;
568
811
  unsubscribe?.();
812
+ for (const timer of deferredRuns.values()) clearTimer(timer);
813
+ deferredRuns.clear();
569
814
  for (const controller of inFlight.values()) controller.abort();
570
815
  inFlight.clear();
571
816
  lastRunAt.clear();