@modusensus/dsh-mneme 0.8.5 → 0.8.7
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 +45 -147
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +289 -59
- package/lib/config.js +78 -11
- package/lib/content-hash.js +54 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream/sleep.js +122 -8
- package/lib/dream.js +1354 -1347
- package/lib/index.js +115 -14
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/local-embedder.js +44 -6
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +6 -3
- package/lib/store.js +248 -21
- package/lib/tools.js +27 -9
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +78 -11
- package/src/content-hash.js +54 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream/sleep.js +122 -8
- package/src/dream.js +1354 -1347
- package/src/index.js +115 -14
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/local-embedder.js +44 -6
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +6 -3
- package/src/store.js +248 -21
- package/src/tools.js +27 -9
- package/src/write-admission.js +259 -0
- package/test/api.test.js +15 -2
- package/test/client.test.js +90 -5
- package/test/content-hash.test.js +204 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +136 -0
- package/test/local-embedder.test.js +45 -2
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/sleep.test.js +117 -0
- package/test/write-admission.test.js +345 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dsh-mneme/src/document-index.js
|
|
2
|
+
// documentDir 的索引(issue #296 第二批)。`<documentDir>/index.md` 整文件机器所有,
|
|
3
|
+
// 可从库随时重建:内容里没有任何「生成时间」,所以删掉再同步一次得到逐字节相同的
|
|
4
|
+
// 文件(test/document-dir.test.js 钉住这一条)。
|
|
5
|
+
//
|
|
6
|
+
// 与镜像侧 documents.md 的分工(#296 第 2 节表格):这里是 document 子系统自己的
|
|
7
|
+
// 索引(id + 标题 + 定位 + managed 标记),那份是带 frontmatter 的镜像只读视图
|
|
8
|
+
// (多一个摘要首句)。两份都只列指针行,都不含正文——正文永远在 doc_path 指向的
|
|
9
|
+
// 文件里,那是 agent 的东西。
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { STR } from "./lang.js";
|
|
13
|
+
import { isManagedDocumentPath } from "./document.js";
|
|
14
|
+
|
|
15
|
+
function oneLine(text) {
|
|
16
|
+
return String(text ?? "").replace(/\s+/g, " ").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} dir resolved documentDir (see resolveDocumentDir)
|
|
21
|
+
* @param {"zh"|"en"} language instance language, same as the mirror
|
|
22
|
+
*/
|
|
23
|
+
export function createDocumentIndex(dir, language = "zh") {
|
|
24
|
+
const filePath = join(dir, "index.md");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the whole file from the given rows. Sorted updated_at DESC, id ASC:
|
|
28
|
+
* the same store state must always render the same bytes (rebuildable).
|
|
29
|
+
*/
|
|
30
|
+
function render(memories) {
|
|
31
|
+
const items = (memories ?? []).slice().sort((a, b) => {
|
|
32
|
+
if (a.updated_at !== b.updated_at) return a.updated_at < b.updated_at ? 1 : -1;
|
|
33
|
+
return a.id < b.id ? -1 : 1;
|
|
34
|
+
});
|
|
35
|
+
const lines = [STR.documentIndexHeader[language]()];
|
|
36
|
+
for (const m of items) {
|
|
37
|
+
const where = isManagedDocumentPath(dir, m.doc_path) ? "managed" : "external";
|
|
38
|
+
// doc_path 理论上必带(store.saveDocument 是唯一铸造口),缺了就只写定位
|
|
39
|
+
// 标记,不编造路径。
|
|
40
|
+
const path = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
|
|
41
|
+
lines.push(`- \`${m.id}\` **${oneLine(m.title)}** · ${where}${path}`);
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure the directory exists (recursive). Never throws: callers warn and move
|
|
48
|
+
* on — a managed directory that cannot be created must not fail plugin boot.
|
|
49
|
+
*/
|
|
50
|
+
function ensure() {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
return { ok: true };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write the index if it changed. Never throws: a failed index write must not
|
|
61
|
+
* fail the business write that triggered it (same fail-safe shape as
|
|
62
|
+
* mirror.sync). Returns { ok, changed } or { ok: false, error }.
|
|
63
|
+
*/
|
|
64
|
+
function sync(memories) {
|
|
65
|
+
const text = render(memories);
|
|
66
|
+
let prev = null;
|
|
67
|
+
try { prev = readFileSync(filePath, "utf8"); } catch { /* first write */ }
|
|
68
|
+
if (prev === text) return { ok: true, changed: false };
|
|
69
|
+
const ready = ensure();
|
|
70
|
+
if (!ready.ok) return { ok: false, error: ready.error };
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(filePath, text, "utf8");
|
|
73
|
+
return { ok: true, changed: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drop the index file (闸关时用)。闸关意味着 document 子系统整体退出,留一份
|
|
81
|
+
* 陈旧索引会列出已归档、已遗忘的指针行——正是 documents.md 在闸关时被镜像删掉
|
|
82
|
+
* 要避免的那种「看着还在、其实已关」的视图。删掉不从库取数,O(1),也不抛。
|
|
83
|
+
*/
|
|
84
|
+
function remove() {
|
|
85
|
+
try {
|
|
86
|
+
rmSync(filePath, { force: true });
|
|
87
|
+
return { ok: true };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { filePath, render, sync, ensure, remove };
|
|
94
|
+
}
|
package/src/document.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { statSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
17
17
|
import { scopeKeyOf } from "./scope.js";
|
|
18
18
|
import { cosineSimilarity } from "./dream/clustering.js";
|
|
19
19
|
|
|
@@ -23,26 +23,71 @@ import { cosineSimilarity } from "./dream/clustering.js";
|
|
|
23
23
|
// 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
|
|
24
24
|
const DOCUMENT_MIN_SIM = 0.92;
|
|
25
25
|
const CANDIDATE_LIMIT = 200;
|
|
26
|
+
const DOCUMENT_DIR_NAME = "documents";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
|
|
30
|
+
* `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
|
|
31
|
+
* memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
|
|
32
|
+
* 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDocumentDir(memoryDir, configured = "") {
|
|
35
|
+
const raw = String(configured ?? "").trim();
|
|
36
|
+
if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
|
|
37
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
38
|
+
return resolve(homedir(), raw.slice(2));
|
|
39
|
+
}
|
|
40
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
|
|
45
|
+
* 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
|
|
46
|
+
* Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
|
|
47
|
+
* `c:\A\Documents\x.md` 会被判成两类。
|
|
48
|
+
*/
|
|
49
|
+
export function isManagedDocumentPath(dir, path) {
|
|
50
|
+
if (!dir || !path) return false;
|
|
51
|
+
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
52
|
+
const base = norm(resolve(dir));
|
|
53
|
+
const target = norm(resolve(path));
|
|
54
|
+
const prefix = base.endsWith(sep) ? base : base + sep;
|
|
55
|
+
return target === base || target.startsWith(prefix);
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
// 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
|
|
28
59
|
// 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
|
|
29
60
|
// id + 旧行 content_history 里的旧摘要(source=superseded)。
|
|
30
61
|
const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
|
|
31
62
|
|
|
63
|
+
// 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
|
|
64
|
+
// 行各有自己的生命周期,被归档会各自留下第二行——
|
|
65
|
+
// document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
|
|
66
|
+
// 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
|
|
67
|
+
// 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
|
|
68
|
+
// summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
|
|
69
|
+
// 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
|
|
70
|
+
// 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
|
|
71
|
+
const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
|
|
72
|
+
|
|
32
73
|
/**
|
|
33
74
|
* Build the document registrar. Injected deps are service.js closure members
|
|
34
75
|
* (store / embedQuery / transaction / finalize) so this module stays free of
|
|
35
76
|
* service-internal wiring; `finalize` is the single write epilogue (mirror
|
|
36
77
|
* sync + notify + re-embed) so callers never duplicate it.
|
|
37
78
|
*
|
|
38
|
-
*
|
|
79
|
+
* `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
|
|
80
|
+
* 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
|
|
81
|
+
* 方向反了会成环。
|
|
82
|
+
*
|
|
83
|
+
* @returns {(payload: object, opts?: object) => Promise<object>}
|
|
39
84
|
* `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
|
|
40
|
-
* evidence_dropped, degraded }`
|
|
85
|
+
* evidence_dropped, evidence_archived, degraded }`
|
|
41
86
|
* @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
|
|
42
87
|
* 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
|
|
43
88
|
*/
|
|
44
|
-
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize }) {
|
|
45
|
-
return async function registerDocument(payload, { hiddenEvidenceIds = [] } = {}) {
|
|
89
|
+
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
|
|
90
|
+
return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
|
|
46
91
|
// opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
|
|
47
92
|
// 指回配置键,agent 能准确转告用户去开。
|
|
48
93
|
if (config?.documentMemoryEnabled !== true) {
|
|
@@ -90,15 +135,14 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
90
135
|
const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
|
|
91
136
|
const kept = [];
|
|
92
137
|
const dropped = [];
|
|
138
|
+
// 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
|
|
139
|
+
// supersede 目标探测后的「认回」),不能一律按「不可用」处理。
|
|
140
|
+
const archivedRefs = new Set();
|
|
93
141
|
for (const id of wanted) {
|
|
94
142
|
const row = hidden.has(id) ? null : store.getById(id);
|
|
143
|
+
if (row?.archived) archivedRefs.add(id);
|
|
95
144
|
(row && !row.archived ? kept : dropped).push(id);
|
|
96
145
|
}
|
|
97
|
-
if (wanted.length > 0 && kept.length === 0) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
146
|
|
|
103
147
|
const title = String(payload?.title ?? "").trim();
|
|
104
148
|
if (!title) throw new Error("registerDocument: title is required");
|
|
@@ -131,6 +175,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
131
175
|
.filter((m) => scopeMatches(m));
|
|
132
176
|
const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
|
|
133
177
|
?? scopeMatched.find((m) => m.title.trim() === title);
|
|
178
|
+
// 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
|
|
179
|
+
// archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
|
|
180
|
+
// dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
|
|
181
|
+
// **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
|
|
182
|
+
// 背书;捏造与跨 scope 照旧拒绝。
|
|
183
|
+
if (explicitTarget) {
|
|
184
|
+
const own = new Set(explicitTarget.evidence ?? []);
|
|
185
|
+
for (const id of archivedRefs) {
|
|
186
|
+
if (!own.has(id)) continue;
|
|
187
|
+
const at = dropped.indexOf(id);
|
|
188
|
+
if (at >= 0) dropped.splice(at, 1);
|
|
189
|
+
kept.push(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (wanted.length > 0 && kept.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
134
197
|
if (!explicitTarget) {
|
|
135
198
|
// vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
|
|
136
199
|
// (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
|
|
@@ -206,7 +269,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
206
269
|
store.setArchived(loser.id, true);
|
|
207
270
|
superseded = store.getById(loser.id);
|
|
208
271
|
}
|
|
209
|
-
|
|
272
|
+
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
273
|
+
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
274
|
+
// 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
|
|
275
|
+
// 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
|
|
276
|
+
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
277
|
+
// (可恢复),opt-out 走 archiveEvidence。
|
|
278
|
+
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
279
|
+
let evidenceArchived = 0;
|
|
280
|
+
if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
|
|
281
|
+
for (const id of kept) {
|
|
282
|
+
// 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
|
|
283
|
+
const row = store.getById(id);
|
|
284
|
+
if (!row || row.archived || row.forgotten) continue;
|
|
285
|
+
if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
286
|
+
store.setArchived(id, true);
|
|
287
|
+
evidenceArchived += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { created, superseded, evidenceArchived };
|
|
210
291
|
});
|
|
211
292
|
finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
|
|
212
293
|
return {
|
|
@@ -215,6 +296,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
215
296
|
...(result.superseded ? { superseded: result.superseded } : {}),
|
|
216
297
|
evidence_kept: kept.length,
|
|
217
298
|
evidence_dropped: dropped.length,
|
|
299
|
+
evidence_archived: result.evidenceArchived,
|
|
218
300
|
degraded: dropped.length > 0
|
|
219
301
|
};
|
|
220
302
|
};
|
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;
|