@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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-1275%20passed-success)](https://github.com/slow-stack/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1290%20passed-success)](https://github.com/slow-stack/dsh-mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/dsh-mneme/ci.yml)](https://github.com/slow-stack/dsh-mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -189,11 +189,37 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
189
189
 
190
190
  ### LLM 消耗审计 📊(v0.4.6,默认开)
191
191
 
192
- 每次**后台 LLM 调用**(autoDream 整理 + 摘要、autoSummarize 压缩)都会写入 `llm_audit_logs` 表:`tokens` / `duration` / `status` / `source`(由哪个触发产生)。失败调用记为 `status=error`,绝不阻塞功能本体;`retentionDays`(默认 90)在启动时清理超期行。新增两个只读 API:
192
+ 每次**后台 LLM 调用**都会写入 `llm_audit_logs` 表:`tokens` / `duration` / `status` / `source`(由哪个触发产生)。失败调用记为 `status=error`,绝不阻塞功能本体;`retentionDays`(默认 90)在启动时清理超期行。
193
+
194
+ 覆盖的后台调用(`source` 即触发源,`operation_type` 区分同一源下的不同调用):
195
+
196
+ | `source` | `operation_type` | 说明 |
197
+ |---|---|---|
198
+ | `autoDream` | `dream_consolidate` / `dream_summarize` | 巩固的整理裁决与总览摘要,各一行 |
199
+ | `autoDream` | `dream_narrative` | 叙述条合成,需 `dreamNarrativeEnabled`(默认关)才跑 |
200
+ | `autoSummarize` | `summarize_compress` | 空闲蒸馏的压缩 |
201
+ | `sleep` | `sleep_conflict` / `sleep_pattern` | Sleep Mode 的冲突裁决与模式挖掘,两阶段各一行 |
202
+ | `entityExtract` | `entity_extract` | 实体抽取,**每次写入记忆都会跑** |
203
+
204
+ > 后两条链路见 #250——它们是结构上漏接的后台路径(sleep 的 `streamText` 副本没有 `onUsage`、实体抽取适配器拿不到 `service`),不是刻意收窄口径。
205
+
206
+ 新增两个只读 API:
193
207
 
194
208
  - `GET /api/dsh-mneme/semantic/llm-audit?page=&pageSize=&source=` — 分页查询 + 按 source 过滤
195
209
  - `GET /api/dsh-mneme/semantic/llm-audit/stats?days=` — 近 N 天按 source 汇总预算(tokens / 次数 / 失败数)
196
210
 
211
+ `/llm-audit/stats` 的 `by_source` 是服务端 `GROUP BY trigger_source`,不做任何白名单过滤,因此新链路自动进入汇总。
212
+
213
+ 面板侧现状(#250 之后,未改面板):
214
+
215
+ - 「LLM 消耗」卡读 `/llm-audit/stats`,但只渲染 `total_calls` / `total_tokens` 两个总数。新链路的 token 与次数**已经计入这两个总数**,所以「mneme 总共花了多少」现在是对的;
216
+ - 按链路的拆分(`by_source`)目前只能从 API 读,面板不展示——「哪条链路最费」在面板上还答不了;
217
+ - 状态页活动流仍只显示 `autoDream` / `autoSummarize` 两个硬编码来源(`lib/client.js`),新链路不会出现在活动流里。
218
+
219
+ 这三处要动面板(活动流还有一处来源白名单加一处二元标签兜底,且 `pageSize=12` 会被每次写入都跑的实体抽取占满),留到面板批次,不在 #250 内。
220
+
221
+ > 实体抽取是**每次写入记忆都会跑**的链路,计入审计后 `llm_audit_logs` 的行数会随写入量线性增长(`retentionDays` 只约束上界、不约束增速)。若实测增速过快,再考虑按 `operation_type` 采样或单独保留期。
222
+
197
223
  > 只读端点,与 list/search/semantic 一样在设置 `apiToken` 后仍保持开放。
198
224
 
199
225
  ### 记忆库主区视图与记忆图谱 🕸️(v0.5.0)
@@ -234,6 +260,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
234
260
 
235
261
  | 版本 | 亮点 |
236
262
  |------|------|
263
+ | **v0.8.6** | 可靠性修复批次:autoDream / sleep 的节流与冷却时刻跨重启持久化——调度器 `lastRunAt` 此前只活在内存,进程重启即归零、闸门对新实例放行(#89 实测横跨重启边界的 8.7 / 23.1 分钟连发),现从 `dream_runs` 审计表恢复上次开跑时刻(按 `run_type` 过滤,failed/degraded 也算 run),审计行 `created_at` 同步改记开跑时刻(#291);Sleep Mode 与实体抽取接入 LLM 审计(#250,#286);本地嵌入按模型族选池化——BGE 系用 CLS,修 mean 池化造成的静默检索偏差(#285);1290 测试全绿 |
237
264
  | **v0.8.5** | 主动整理 + 注入形态 + 蒸馏可靠性批次:agent 主动整理接口 `service.organize`——`dryRun` 出比对报告(精确层标题归一,向量层 0.92 与 document / session 档同源)→ agent 判断 → `apply` 落库,筛除=归档不删、同一份报告只许落地一次、全程复用 `dream_runs` 回执(#231/#267,heptaspirit);注入形态第一批——`injectGuidanceEnabled` 能力说明(工具描述尾句 + order 150 常驻段)与 `pinnedInjectBudget` 约束/偏好 pin 池逐字保真(#249);注入预览卡 `/inject-preview` 旁路快照(#179);蒸馏游标持久化 `distill_cursors` 与旧宿主内存游标降级(#229/#274/#279);工具暴露开关 `disableMemorySearch` / `disableMemoryArchive`(#276);document 型记忆(#230)、总览独立路由(#258)、错峰队列(#239 第 4 项)、注入命中留痕(#217)、token 记账修复(#242)、运行完整性判据修复(#268);1275 测试全绿 |
238
265
  | **v0.8.4** | MCP 六件套 + 图召回轴 + 冷启动 + 注入截断/状态条 + 蒸馏可靠性批次:stdio MCP server 让记忆六件套进任意 MCP 客户端——零依赖(JSON-RPC 2.0 换行帧,不引 SDK),工具面与 `src/tools.js` 逐字对齐、平价回归锁漂移,配置沿用 CLI 约定(#181/#214);图召回轴 `entityRecallEnabled`——检索融合池三源扩四源,实体挂联记忆参与 blend/rrf/minmax,与 BM25 同为确认/回填信号(#219/#222);冷启动 `src/bootstrap.js` 从仓库文件反向构建初始记忆——零 LLM 必有产出、幂等,`POST /bootstrap`(#220/#223);注入截断上限可配 `injectContentMaxChars` + 截断尾部带全文 `memory_get` 指引(#164①/#225);dream 总览升级常驻状态条 + 叙述条 `dreamNarrativeEnabled`(#164/#227/#228);记忆复用统计卡 / 注入状态卡 / 路由旧值显式标记(#182/#217/#191/#213);heat 幂律换广义指数 `heatGlobalBeta`(#218);修复 reasoningEffort 连通性透传、summarize 增量蒸馏 + 子会话交付补齐(#215/#226/#232);1160 测试全绿 |
239
266
  | **v0.8.3** | 注入轮换与下载可靠性批次:注入位跨轮轮换 `injectRotationTurns`(默认 0 = 关)——同一会话相邻轮次不再反复注入同一条,窗口只看之前轮次、同查询工具轮不推进不转自己(sessionId 分桶 FIFO 32,#205/#206);候选池四处扩容 `poolSize = maxItems × (轮换窗口 + 1)`(下限 200),旋钮开多大、池子就够多大(#208);模型文件下载断点续传与重试 `resilientModelDownload`(默认开)——Range / If-Range 续传、416 / 偏移失配重置、单写者锁 + 降级直通 + 空闲看门狗(#194/#207);1079 测试全绿 |
@@ -352,6 +379,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
352
379
 
353
380
  | **v0.8.4** | ✅ 完成 | MCP 六件套 + 图召回轴 + 冷启动 + 注入截断/状态条 + 蒸馏可靠性 | stdio MCP server 让记忆六件套进任意 MCP 客户端——零依赖(JSON-RPC 2.0 换行帧,不引 SDK)、工具面与 `src/tools.js` 逐字对齐、平价回归锁漂移,配置沿用 CLI 约定(#181/#214);图召回轴 `entityRecallEnabled`——检索融合池三源扩四源,实体挂联记忆参与 blend/rrf/minmax,与 BM25 同为确认/回填信号(#219/#222);冷启动 `src/bootstrap.js` 从仓库文件反向构建初始记忆——零 LLM 必有产出、幂等,`POST /bootstrap`(#220/#223);注入截断上限可配 `injectContentMaxChars` + 截断尾部带全文 `memory_get` 指引(#164①/#225);dream 总览常驻状态条 + 叙述条 `dreamNarrativeEnabled`(#164/#227/#228);记忆复用统计卡 / 注入状态卡 / 路由旧值显式标记(#182/#217/#191/#213);heat 广义指数 `heatGlobalBeta`(#218);reasoningEffort 连通性透传 + summarize 增量蒸馏与子会话交付(#215/#226/#232);1160 测试全绿 |
354
381
  | **v0.8.5** | ✅ 完成 | 主动整理接口 + 注入形态 + 蒸馏可靠性 | agent 主动整理接口 `service.organize`(`dryRun` 比对报告 → agent 判断 → `apply` 落库;筛除=归档不删、同一份报告只许落地一次、全程复用 `dream_runs` 回执,#231/#267,heptaspirit);注入形态第一批——`injectGuidanceEnabled` 能力说明段与 `pinnedInjectBudget` 约束/偏好 pin 池(#249)、注入预览卡(#179);蒸馏游标持久化 `distill_cursors` 与旧宿主内存游标降级(#229/#274/#279);工具暴露开关 `disableMemorySearch` / `disableMemoryArchive`(#276);document 型记忆(#230);总览独立路由(#258);错峰队列(#239 第 4 项);1275 测试全绿 |
382
+ | **v0.8.6** | ✅ 完成 | 可靠性修复批次(autoDream 连发治理 + 审计覆盖 + 嵌入质量) | autoDream / sleep 节流与冷却时刻跨重启持久化——`lastRunAt` 从 `dream_runs` 审计表恢复(按 `run_type` 过滤),审计行改记开跑时刻,重启不再绕过最小间隔 / 冷却闸(#89/#291);Sleep Mode 与实体抽取接入 LLM 审计 `llm_audit_logs`(#250/#286);本地嵌入按模型族选池化(BGE → CLS,#285);1290 测试全绿 |
355
383
 
356
384
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
357
385
 
@@ -685,7 +713,7 @@ src/
685
713
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
686
714
  └── index.js # 插件接线
687
715
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
688
- test/ # 1275 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
716
+ test/ # 1290 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
689
717
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
690
718
  ```
691
719
 
@@ -694,7 +722,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
694
722
  ```bash
695
723
  cd dsh-mneme
696
724
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
697
- npm test # 运行 1275 个测试
725
+ npm test # 运行 1290 个测试
698
726
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
699
727
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
700
728
  ```
package/lib/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;