@modusensus/dsh-mneme 0.8.5 → 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.
package/lib/index.js CHANGED
@@ -43,7 +43,15 @@ export { Config };
43
43
  // unit-testable; the extractor only ever sees a callLLM(messages, options)
44
44
  // => Promise<string>. The route always carries a real provider/model (dsh-llm
45
45
  // GenerateOptions requires both) — never a bare stream.
46
- export function createEntityStreamAdapter({ llm, agentDefaultModel, logger }) {
46
+ //
47
+ // Issue #250: it also accounts for itself. Entity extraction is the background
48
+ // LLM path that runs on every memory write, yet it never reached
49
+ // llm_audit_logs — the adapter only ever held an llm handle, so it had no way
50
+ // to call service.saveLlmAudit. It now takes service (+ config for the shared
51
+ // llmAudit.enabled gate) and writes one row per stream attempt, matching the
52
+ // contract of runAuditedLlm in dream.js: a rejected effort attempt records its
53
+ // own error row and the retry records its own success row.
54
+ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, service, config }) {
47
55
  return async function streamEntityText(messages, options = {}) {
48
56
  let route = {};
49
57
  if (options.provider) route.provider = options.provider;
@@ -58,8 +66,38 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger }) {
58
66
  } catch { /* fall through to whatever route we already have */ }
59
67
  }
60
68
  const effort = options.reasoningEffort;
69
+ // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
70
+ // 无路由早退同口径,那种情况不写审计行。
71
+ const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
61
72
  const tryStream = (withEffort) => {
62
73
  let text = "";
74
+ let inputTokens = 0;
75
+ let outputTokens = 0;
76
+ const startedAt = Date.now();
77
+ const timestamp = new Date(startedAt).toISOString();
78
+ // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
79
+ // 的 fail-safe 硬约定)。
80
+ const writeAudit = (status, errorMessage) => {
81
+ if (config?.llmAudit?.enabled === false || !modelId || typeof service?.saveLlmAudit !== "function") return;
82
+ try {
83
+ service.saveLlmAudit({
84
+ timestamp,
85
+ trigger_source: "entityExtract",
86
+ operation_type: "entity_extract",
87
+ model_id: modelId,
88
+ input_tokens: inputTokens,
89
+ output_tokens: outputTokens,
90
+ total_tokens: inputTokens + outputTokens,
91
+ cost_usd: 0,
92
+ duration_ms: Date.now() - startedAt,
93
+ status,
94
+ error_message: errorMessage,
95
+ related_memory_ids: []
96
+ });
97
+ } catch (auditError) {
98
+ logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
99
+ }
100
+ };
63
101
  return (async () => {
64
102
  for await (const chunk of llm.stream({
65
103
  ...route,
@@ -68,11 +106,26 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger }) {
68
106
  messages
69
107
  })) {
70
108
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
71
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) return undefined;
109
+ // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接
110
+ // 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与
111
+ // dream.js 的 streamText 同一处理。
112
+ if (chunk.type === "usage") {
113
+ const u = chunk.usage ?? chunk;
114
+ const i = u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? u.promptTokens;
115
+ const o = u.output_tokens ?? u.outputTokens ?? u.completion_tokens ?? u.completionTokens;
116
+ if (Number.isFinite(i)) inputTokens = i;
117
+ if (Number.isFinite(o)) outputTokens = o;
118
+ }
119
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
120
+ writeAudit("error", "llm stream aborted or errored");
121
+ return undefined;
122
+ }
72
123
  }
124
+ writeAudit("success", null);
73
125
  return text;
74
126
  })().catch((err) => {
75
127
  logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
128
+ writeAudit("error", String(err?.message ?? err));
76
129
  return undefined;
77
130
  });
78
131
  };
@@ -273,6 +326,9 @@ export const apply = (ctx, config) => {
273
326
  dimension: cfg.localEmbedDimension,
274
327
  device: cfg.localEmbedDevice,
275
328
  batchSize: cfg.localEmbedBatchSize,
329
+ // 池化方式必须与模型的训练口径一致(BGE 系 = CLS)。它既进 embed() 的调用,
330
+ // 也进 modelHash —— 池化改了就是换向量空间,既有索引会被判失配并重建。
331
+ pooling: cfg.localEmbedPooling,
276
332
  cacheDir: cfg.embedModelCacheDir,
277
333
  runtimeDir: cfg.runtimeDir,
278
334
  // #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。
@@ -403,6 +459,7 @@ export const apply = (ctx, config) => {
403
459
  minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
404
460
  logger: ctx.logger,
405
461
  semantic: { embedder, vectorIndex },
462
+ lastRunAtSeed: store.lastDreamRunAt("auto"),
406
463
  onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
407
464
  });
408
465
  service.setDreamHook(() => dream.maybeSchedule(service));
@@ -419,6 +476,7 @@ export const apply = (ctx, config) => {
419
476
  service,
420
477
  config: cfg,
421
478
  logger: ctx.logger,
479
+ lastRunAtSeed: store.lastDreamRunAt("sleep"),
422
480
  onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
423
481
  });
424
482
  service.setSleepHook(() => sleep.noteWrite());
@@ -442,7 +500,11 @@ export const apply = (ctx, config) => {
442
500
  const streamEntityText = createEntityStreamAdapter({
443
501
  llm: ctx.llm,
444
502
  agentDefaultModel: ctx.agentDefaultModel,
445
- logger: ctx.logger
503
+ logger: ctx.logger,
504
+ // Issue #250: the adapter needs service to write its llm_audit_logs row
505
+ // (it never had it) and config for the shared llmAudit.enabled gate.
506
+ service,
507
+ config: cfg
446
508
  });
447
509
  service.setEntityExtractor((memory) =>
448
510
  extractEntities(memory, { store, config: cfg, callLLM: streamEntityText, logger: ctx.logger })
@@ -21,6 +21,32 @@ function modelHash(model) {
21
21
  return `${model}#${hashString(model)}`;
22
22
  }
23
23
 
24
+ /**
25
+ * Model families whose embedding is the CLS token rather than a mean over tokens.
26
+ *
27
+ * BGE (BAAI/bge-*) is trained that way: the model's own `1_Pooling/config.json`
28
+ * declares `pooling_mode_cls_token: true` / `pooling_mode_mean_tokens: false`, and
29
+ * the official README says "select the last hidden state of the first token" plus
30
+ * L2 normalization. transformers.js's `feature-extraction` defaults to `mean`, so
31
+ * feeding it a BGE model silently produces slightly-off vectors — no error, just a
32
+ * worse ranking. This table is the extension point: a family goes in only once its
33
+ * pooling is confirmed against the model's own `1_Pooling` (or equivalent) config.
34
+ */
35
+ const CLS_POOLED_MODEL_PATTERNS = [/bge/i];
36
+
37
+ /**
38
+ * Resolve the pooling to embed with.
39
+ *
40
+ * `auto` (default) picks by model family — BGE → `cls`, everything else → `mean`,
41
+ * so models that were never affected keep their exact previous behavior. An
42
+ * explicit `cls` / `mean` wins over the family table.
43
+ */
44
+ export function resolvePooling(pooling, model) {
45
+ const mode = String(pooling ?? "auto").trim().toLowerCase();
46
+ if (mode === "cls" || mode === "mean") return mode;
47
+ return CLS_POOLED_MODEL_PATTERNS.some((re) => re.test(String(model ?? ""))) ? "cls" : "mean";
48
+ }
49
+
24
50
  /**
25
51
  * Lazy default loader: dynamic import keeps module load cheap.
26
52
  *
@@ -71,8 +97,9 @@ function tensorToRows(tensor) {
71
97
 
72
98
  /**
73
99
  * ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
74
- * Runs fully offline with mean pooling + L2 normalization for BERT-style
75
- * models like bge-small-zh. `engineFactory` is injectable for tests.
100
+ * Runs fully offline; pooling is resolved per model family (BGE → CLS, else mean —
101
+ * see resolvePooling) and vectors are L2-normalized. `engineFactory` is injectable
102
+ * for tests.
76
103
  */
77
104
  export class LocalEmbedder {
78
105
  constructor(opts = {}) {
@@ -87,6 +114,10 @@ export class LocalEmbedder {
87
114
  // (含测试)保持裸 fetch 的现状,配置默认值由 index.js 传进来。
88
115
  this.resilientModelDownload = opts.resilientModelDownload === true;
89
116
  this.useDtype = opts.useDtype || "q8";
117
+ // 池化:配置值(auto/cls/mean)→ 生效值(cls/mean)。解析结果既进 embed(),
118
+ // 也进 modelHash —— 换池化就是换向量空间,旧索引必须被判失配后重建。
119
+ this.poolingMode = String(opts.pooling ?? "auto").trim().toLowerCase();
120
+ this.pooling = resolvePooling(this.poolingMode, this.model);
90
121
  // 模型镜像下载源(embedModelMirror,#188 补充):空 = transformers 默认。
91
122
  this.remoteHost = String(opts.remoteHost ?? "").trim();
92
123
  this.logger = opts.logger ?? null;
@@ -114,19 +145,19 @@ export class LocalEmbedder {
114
145
  this.extractor = await this.engineFactory("feature-extraction", this.model, options);
115
146
  this.ready = true; // service reads this to flush queued re-embeds
116
147
  this.logger?.info?.(
117
- `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
148
+ `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device}, pooling=${this.pooling})`
118
149
  );
119
150
  return this;
120
151
  }
121
152
 
122
- /** Embed many texts with mean pooling; chunks at batchSize. */
153
+ /** Embed many texts with the model's pooling; chunks at batchSize. */
123
154
  async embed(texts) {
124
155
  if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
125
156
  if (!this.extractor) throw new Error("LocalEmbedder not initialized");
126
157
  const out = [];
127
158
  for (let i = 0; i < texts.length; i += this.batchSize) {
128
159
  const chunk = texts.slice(i, i + this.batchSize);
129
- const tensor = await this.extractor(chunk, { pooling: "mean", normalize: true });
160
+ const tensor = await this.extractor(chunk, { pooling: this.pooling, normalize: true });
130
161
  out.push(...tensorToRows(tensor));
131
162
  }
132
163
  return out;
@@ -142,7 +173,14 @@ export class LocalEmbedder {
142
173
  }
143
174
 
144
175
  get modelHash() {
145
- return modelHash(this.model);
176
+ // Pooling is part of the vector space: a CLS index cannot be queried with mean
177
+ // vectors (or vice versa). Keep the historical `model#hash` shape for `mean` so
178
+ // indexes that were never affected are not invalidated needlessly; every other
179
+ // pooling gets a fingerprint of its own, which makes the index-consistency gate
180
+ // flag the stale mean-space index and rebuild it.
181
+ return this.pooling === "mean"
182
+ ? modelHash(this.model)
183
+ : modelHash(`${this.model}@${this.pooling}`);
146
184
  }
147
185
 
148
186
  dispose() {
package/lib/settings.js CHANGED
@@ -172,8 +172,10 @@ const FEATURE_FLAG_ENUMS = {
172
172
  summarizeDedupeMode: ["off", "title", "vector"],
173
173
  // Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
174
174
  sleepActionSet: ["conflict", "full"],
175
- // Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
176
- dreamCandidateMode: ["window", "hybrid"]
175
+ // Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
176
+ dreamCandidateMode: ["window", "hybrid"],
177
+ // 本地嵌入池化:auto = 按模型族判定(BGE → cls),可显式覆盖为 cls / mean。
178
+ localEmbedPooling: ["auto", "cls", "mean"]
177
179
  };
178
180
  const FEATURE_FLAG_STRING_MAX = 200;
179
181
 
package/lib/store.js CHANGED
@@ -1532,6 +1532,22 @@ export function createStore(path) {
1532
1532
  return rows.map(toDreamRun);
1533
1533
  }
1534
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
+
1535
1551
  /**
1536
1552
  * Latest ruling-rule version seen on the audit trail. policy_epoch is a config
1537
1553
  * value stamped onto each run by the caller; reading the newest row's epoch
@@ -2486,6 +2502,7 @@ export function createStore(path) {
2486
2502
  needsEmbedding,
2487
2503
  searchVector,
2488
2504
  saveDreamRun,
2505
+ lastDreamRunAt,
2489
2506
  getDreamRun,
2490
2507
  listDreamRuns,
2491
2508
  getLatestPolicyEpoch,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.8.5",
4
+ "version": "0.8.6",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/config.js CHANGED
@@ -248,6 +248,18 @@ export const Config = z.object({
248
248
  localEmbedDimension: z.natural().default(512),
249
249
  localEmbedDevice: z.union([z.const("cpu"), z.const("gpu")]).default("cpu"),
250
250
  localEmbedBatchSize: z.natural().min(1).max(64).default(8),
251
+ // 本地嵌入的池化方式(**bug 修复,不是新能力**):BGE 系是按 CLS 池化训练的——
252
+ // 模型自带的 `1_Pooling/config.json` 明确写着 `pooling_mode_cls_token: true` /
253
+ // `pooling_mode_mean_tokens: false`,官方 README 也是「select the last hidden state of
254
+ // the first token」+ L2 normalize;而 transformers.js 的 `feature-extraction` 默认 mean。
255
+ // ⇒ 此前本地嵌入对 BGE 系一直用错池化:**不报错、只是向量系统性偏差**,检索质量静默受损。
256
+ // 'auto' = 按模型族判定(BGE → cls,其余 → mean,未受影响的模型行为不变);也可显式钉住。
257
+ // ⚠️ 池化决定向量空间,改它会改变 modelHash(见 local-embedder.js),既有索引会被判失配并重建。
258
+ localEmbedPooling: z.union([
259
+ z.const("auto"),
260
+ z.const("cls"),
261
+ z.const("mean")
262
+ ]).default("auto"),
251
263
 
252
264
  // Ollama embedder.
253
265
  ollamaBaseUrl: z.string().default("http://localhost:11434"),
@@ -488,11 +500,18 @@ export const Config = z.object({
488
500
  }).default({}),
489
501
 
490
502
  // --- LLM audit trail (Bug8) ------------------------------------------------
491
- // Records every background LLM call (autoDream consolidation + summary,
492
- // autoSummarize compression) into llm_audit_logs: tokens, duration, status
493
- // and which trigger produced it. Failures are recorded as status=error and
494
- // never block the feature. retentionDays bounds the table: older rows are
495
- // purged on boot.
503
+ // Records every background LLM call into llm_audit_logs: tokens, duration,
504
+ // status and which trigger produced it. Failures are recorded as
505
+ // status='error' and never block the feature. retentionDays bounds the table:
506
+ // older rows are purged on boot.
507
+ //
508
+ // Covered trigger_source values: autoDream (consolidation + summary),
509
+ // autoSummarize (compression), sleep (conflict + pattern) and entityExtract
510
+ // (issue #250 — the last two were structurally missing the hook, not a
511
+ // deliberate narrowing). entityExtract runs on every memory write, so row
512
+ // growth scales with write volume; retentionDays caps the ceiling but not the
513
+ // rate. If that turns out too fast, sample by operation_type or give it a
514
+ // separate retention — do not add a second gate: this flag is the single one.
496
515
  llmAudit: z.object({
497
516
  enabled: z.boolean().default(true),
498
517
  retentionDays: z.natural().min(1).max(3650).default(90)
@@ -49,12 +49,22 @@ function parseJsonArray(text) {
49
49
  }
50
50
  }
51
51
 
52
- /** Same stream consumption contract as dream.js. */
53
- async function streamText(ctx, options, onStreamError) {
52
+ /**
53
+ * Same stream consumption contract as dream.js.
54
+ *
55
+ * `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
56
+ * dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
57
+ * token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
58
+ */
59
+ async function streamText(ctx, options, onUsage, onStreamError) {
54
60
  if (!ctx?.llm?.stream) return undefined;
55
61
  let text = "";
56
62
  for await (const chunk of ctx.llm.stream(options)) {
57
63
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
64
+ // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
65
+ // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
66
+ // 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
67
+ if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
58
68
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
59
69
  // Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
60
70
  // cause instead of discarding it.
@@ -67,6 +77,79 @@ async function streamText(ctx, options, onStreamError) {
67
77
  return text;
68
78
  }
69
79
 
80
+ /**
81
+ * Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
82
+ * 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
83
+ * checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
84
+ *
85
+ * 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
86
+ * fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
87
+ * reportUsage 上报。
88
+ *
89
+ * 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
90
+ * 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
91
+ * 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
92
+ * 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
93
+ * 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
94
+ */
95
+ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
96
+ if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
97
+ return body(() => {});
98
+ }
99
+ const startedAt = Date.now();
100
+ const timestamp = new Date(startedAt).toISOString();
101
+ let inputTokens = 0;
102
+ let outputTokens = 0;
103
+ let status = "success";
104
+ let errorMessage = null;
105
+ try {
106
+ const result = await body((usage) => {
107
+ if (!usage) return;
108
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
109
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
110
+ if (Number.isFinite(i)) inputTokens = i;
111
+ if (Number.isFinite(o)) outputTokens = o;
112
+ });
113
+ if (result === undefined) {
114
+ status = "error";
115
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
116
+ errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
117
+ } else if (typeof spec.auditError === "function") {
118
+ // 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
119
+ // failed 返回值打架。
120
+ const message = spec.auditError(result);
121
+ if (message) {
122
+ status = "error";
123
+ errorMessage = message;
124
+ }
125
+ }
126
+ return result;
127
+ } catch (error) {
128
+ status = "error";
129
+ errorMessage = String(error?.message ?? error);
130
+ throw error;
131
+ } finally {
132
+ try {
133
+ service.saveLlmAudit({
134
+ timestamp,
135
+ trigger_source: spec.triggerSource,
136
+ operation_type: spec.operationType,
137
+ model_id: spec.modelId,
138
+ input_tokens: inputTokens,
139
+ output_tokens: outputTokens,
140
+ total_tokens: inputTokens + outputTokens,
141
+ cost_usd: 0,
142
+ duration_ms: Date.now() - startedAt,
143
+ status,
144
+ error_message: errorMessage,
145
+ related_memory_ids: spec.relatedMemoryIds ?? []
146
+ });
147
+ } catch (auditError) {
148
+ logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
149
+ }
150
+ }
151
+ }
152
+
70
153
  /** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
71
154
  * route as a shared explicit fallback, then the agent default model. Sleep
72
155
  * can pin a cheaper model for its bulk passes without disturbing the dream
@@ -232,7 +315,19 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
232
315
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
233
316
  const runConflict = (withEffort) => {
234
317
  conflictStreamFailure = "";
235
- return streamText(ctx, {
318
+ return auditedSleepLlm(ctx, service, config, logger, {
319
+ triggerSource: "sleep",
320
+ operationType: "sleep_conflict",
321
+ modelId: `${route.provider}:${route.model}`,
322
+ // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
323
+ relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
324
+ streamError: () => conflictStreamFailure,
325
+ // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
326
+ // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
327
+ // 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
328
+ // 多解析一次,换来两条路径彻底解耦。
329
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
330
+ }, (reportUsage) => streamText(ctx, {
236
331
  provider: route.provider,
237
332
  model: route.model,
238
333
  purpose: "sleep-conflict",
@@ -242,7 +337,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
242
337
  { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
243
338
  { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
244
339
  ]
245
- }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
340
+ }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
246
341
  };
247
342
  const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
248
343
  if (text === undefined) {
@@ -385,7 +480,16 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
385
480
  let patternStreamFailure = "";
386
481
  const runPattern = (withEffort) => {
387
482
  patternStreamFailure = "";
388
- return streamText(ctx, {
483
+ return auditedSleepLlm(ctx, service, config, logger, {
484
+ triggerSource: "sleep",
485
+ operationType: "sleep_pattern",
486
+ modelId: `${route.provider}:${route.model}`,
487
+ // 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
488
+ relatedMemoryIds: memories.map((m) => m.id),
489
+ streamError: () => patternStreamFailure,
490
+ // 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
491
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
492
+ }, (reportUsage) => streamText(ctx, {
389
493
  provider: route.provider,
390
494
  model: route.model,
391
495
  purpose: "sleep-pattern",
@@ -395,7 +499,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
395
499
  { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin", plugin: "dsh-mneme" } },
396
500
  { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
397
501
  ]
398
- }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
502
+ }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
399
503
  };
400
504
  const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
401
505
  if (text === undefined) {
@@ -507,6 +611,9 @@ function deriveStatus(phases) {
507
611
  */
508
612
  export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
509
613
  const runId = randomUUID();
614
+ // Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
615
+ // (LLM 各 phase)不应计入重启后的冷却窗口。
616
+ const sleepStartedAt = Date.now();
510
617
  const phases = {};
511
618
  const attempt = async (name, fn) => {
512
619
  if (signal?.aborted) return; // user resumed activity — stop before next phase
@@ -543,6 +650,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
543
650
  try {
544
651
  service.saveDreamRun({
545
652
  id: runId,
653
+ // Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
654
+ // 不该计入重启后的 sleep 冷却种子。
655
+ created_at: new Date(sleepStartedAt).toISOString(),
546
656
  status,
547
657
  provider: route?.provider,
548
658
  model: route?.model,
@@ -582,10 +692,14 @@ export function createSleepScheduler({
582
692
  onRun = null,
583
693
  now = () => Date.now(),
584
694
  setTimeoutFn = setTimeout,
585
- clearTimeoutFn = clearTimeout
695
+ clearTimeoutFn = clearTimeout,
696
+ // Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
697
+ // 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
698
+ // 测试注入 0(默认)即保持旧行为。
699
+ lastRunAtSeed = 0
586
700
  }) {
587
701
  let lastWriteAt = now();
588
- let lastRunAt = 0;
702
+ let lastRunAt = lastRunAtSeed;
589
703
  let running = false;
590
704
  let disposed = false;
591
705
  let idleTimer = null;