@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 +32 -4
- package/lib/config.js +24 -5
- package/lib/dream/sleep.js +122 -8
- package/lib/dream.js +1354 -1347
- package/lib/index.js +65 -3
- package/lib/local-embedder.js +44 -6
- package/lib/settings.js +4 -2
- package/lib/store.js +17 -0
- package/package.json +1 -1
- package/src/config.js +24 -5
- package/src/dream/sleep.js +122 -8
- package/src/dream.js +1354 -1347
- package/src/index.js +65 -3
- package/src/local-embedder.js +44 -6
- package/src/settings.js +4 -2
- package/src/store.js +17 -0
- package/test/api.test.js +3 -2
- package/test/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +94 -0
- package/test/local-embedder.test.js +45 -2
- package/test/sleep.test.js +117 -0
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
|
-
|
|
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
|
-
|
|
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 })
|
package/lib/local-embedder.js
CHANGED
|
@@ -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
|
|
75
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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.
|
|
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
|
|
492
|
-
//
|
|
493
|
-
// and
|
|
494
|
-
//
|
|
495
|
-
//
|
|
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)
|
package/src/dream/sleep.js
CHANGED
|
@@ -49,12 +49,22 @@ function parseJsonArray(text) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
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
|
|
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
|
|
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 =
|
|
702
|
+
let lastRunAt = lastRunAtSeed;
|
|
589
703
|
let running = false;
|
|
590
704
|
let disposed = false;
|
|
591
705
|
let idleTimer = null;
|