@modusensus/dsh-mneme 0.8.6 → 0.8.8
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 +19 -148
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +349 -61
- package/lib/config.js +118 -7
- package/lib/content-hash.js +54 -0
- package/lib/continuity.js +220 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream.js +138 -39
- package/lib/guide.js +18 -0
- package/lib/index.js +87 -12
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/peak-hours.js +117 -0
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +16 -2
- package/lib/store.js +318 -21
- package/lib/summarize.js +52 -113
- package/lib/tools.js +37 -11
- 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 +118 -7
- package/src/content-hash.js +54 -0
- package/src/continuity.js +220 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream.js +138 -39
- package/src/guide.js +18 -0
- package/src/index.js +87 -12
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/peak-hours.js +117 -0
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +16 -2
- package/src/store.js +318 -21
- package/src/summarize.js +52 -113
- package/src/tools.js +37 -11
- package/src/write-admission.js +259 -0
- package/test/api.test.js +21 -3
- package/test/client.test.js +103 -6
- package/test/content-hash.test.js +204 -0
- package/test/continuity.test.js +333 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/dream-failure-backoff.test.js +173 -0
- package/test/dream-peak-hours.test.js +194 -0
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/llm-audit.test.js +42 -0
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/reasoning-effort.test.js +6 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/summarize-reasoning-effort.test.js +189 -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.js
CHANGED
|
@@ -2,6 +2,12 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
|
2
2
|
import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
|
|
3
3
|
import { clusterByTag, intersectEvidence } from "./dream/narratives.js";
|
|
4
4
|
import { scopeKeyOf } from "./scope.js";
|
|
5
|
+
// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的
|
|
6
|
+
// 零依赖模块 peak-hours.js 取(该模块从 summarize.js 抽出,PR #320 review)——
|
|
7
|
+
// 不另写一份解析器,两份实现漂移会让「同一个时段串在两处行为不同」,那比没有
|
|
8
|
+
// 这个功能更糟。此前直接 import summarize.js,#316 后 summarize 反向依赖 dream
|
|
9
|
+
// (withEffortFallback 复用),会成真循环,故抽模块。
|
|
10
|
+
import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
|
|
5
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
12
|
import { STR, langOf } from "./lang.js";
|
|
7
13
|
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
@@ -341,6 +347,9 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
|
|
|
341
347
|
* accepted → reasoning capped; rejected → provider default (old behavior),
|
|
342
348
|
* logged so the rejection is observable.
|
|
343
349
|
*/
|
|
350
|
+
// Issue #315:effort 拒收判别式的单一来源。summarize 的流失败以 aborted 结果
|
|
351
|
+
// 返回,需在折叠成 undefined 前用同一甄别(各处手写会漂移)。
|
|
352
|
+
export const EFFORT_REJECT_RE = /reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i;
|
|
344
353
|
async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
|
|
345
354
|
if (!effort || effort === "none") return attempt();
|
|
346
355
|
try {
|
|
@@ -351,17 +360,20 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
|
|
|
351
360
|
// on the chunk's failure reason here or the retry below is dead code
|
|
352
361
|
// for the stream path.
|
|
353
362
|
const reason = String(getStreamError?.() ?? "");
|
|
354
|
-
if (
|
|
363
|
+
if (EFFORT_REJECT_RE.test(reason)) {
|
|
355
364
|
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
|
|
356
365
|
return fallback();
|
|
357
366
|
}
|
|
358
367
|
}
|
|
359
368
|
return result;
|
|
360
369
|
} catch (error) {
|
|
370
|
+
// dispose/取消中止直接放行,绝不能被误判成 effort 拒收而触发 fallback
|
|
371
|
+
//(取消后重打一次不带 effort 的调用是浪费,且可能掩盖真实的取消意图)。
|
|
372
|
+
if (error?.name === "AbortError") throw error;
|
|
361
373
|
const message = String(error?.message ?? error);
|
|
362
374
|
// matches both "reasoning effort" (natural language) and the bare
|
|
363
375
|
// "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
|
|
364
|
-
if (
|
|
376
|
+
if (!EFFORT_REJECT_RE.test(message)) throw error;
|
|
365
377
|
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
|
|
366
378
|
return fallback();
|
|
367
379
|
}
|
|
@@ -744,8 +756,16 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
|
|
|
744
756
|
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
|
|
745
757
|
}
|
|
746
758
|
|
|
747
|
-
|
|
759
|
+
// Issue #292:连续失败退避的间隔封顶(30 分钟)。封顶只拦指数「增长」,不把
|
|
760
|
+
// 用户配得比这更大的 dreamMinIntervalMinutes 基数压小(见 effectiveMinIntervalMs)。
|
|
761
|
+
const FAILURE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
762
|
+
|
|
763
|
+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, failureBackoff = false, logger, semantic = null, lastRunAtSeed = 0, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
|
|
748
764
|
let pendingTimer = null;
|
|
765
|
+
// Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者
|
|
766
|
+
// 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让
|
|
767
|
+
// maybeSchedule 的守卫在顺延期间把新的写入触发误当成「已有待跑」而吞掉。
|
|
768
|
+
let deferTimer = null;
|
|
749
769
|
let running = false;
|
|
750
770
|
let disposed = false;
|
|
751
771
|
let baseline = { count: 0, chars: 0 };
|
|
@@ -755,6 +775,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
755
775
|
// lastRunAtSeed:调用方从 dream_runs 审计表读出的上次开跑时刻——
|
|
756
776
|
// 内存变量进程重启即归零,闸门对新实例放行 → 重启后立刻连发(#89 根因)。
|
|
757
777
|
let lastRunAt = lastRunAtSeed;
|
|
778
|
+
// Issue #292(#135 派生):同会话内连续失败计数。失败(onRun 抛错或返回
|
|
779
|
+
// ok:false)+1,成功清零;无返回结果的 run(no-op 桩)视为完成且无失败,
|
|
780
|
+
// 不动计数。纯内存变量、宿主重启归零——跨重启的冷却由 lastRunAtSeed(#291)
|
|
781
|
+
// 持久化负责,两者不重叠。
|
|
782
|
+
let consecutiveFailures = 0;
|
|
783
|
+
|
|
784
|
+
// Issue #292:有效最小间隔 = 基数 × 2^连续失败数,封顶 30 分钟。退避关闭或
|
|
785
|
+
// 尚无失败时逐字节返回基数(默认关 = 行为与现状一致)。基数 0 无闸可翻倍
|
|
786
|
+
// (本键不自己产生间隔);封顶取 max(基数, cap),指数再大也不会把用户配的
|
|
787
|
+
// 大基数压小。2^N 溢出成 Infinity 由 Math.min 兜到 cap,无需另设上限位数。
|
|
788
|
+
function effectiveMinIntervalMs() {
|
|
789
|
+
if (!failureBackoff || consecutiveFailures <= 0) return minIntervalMs;
|
|
790
|
+
const cap = Math.max(minIntervalMs, FAILURE_BACKOFF_CAP_MS);
|
|
791
|
+
return Math.min(minIntervalMs * 2 ** consecutiveFailures, cap);
|
|
792
|
+
}
|
|
758
793
|
|
|
759
794
|
function shouldTrigger(service) {
|
|
760
795
|
const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
|
|
@@ -766,51 +801,115 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
766
801
|
}
|
|
767
802
|
|
|
768
803
|
function maybeSchedule(service) {
|
|
769
|
-
if (disposed || running || pendingTimer) return false;
|
|
770
|
-
// Issue #89(请求 2
|
|
771
|
-
|
|
804
|
+
if (disposed || running || pendingTimer || deferTimer) return false;
|
|
805
|
+
// Issue #89(请求 2):最小触发间隔闸门。#292 退避开启时改用指数放大的
|
|
806
|
+
// 有效间隔:连续失败越多,下次放行越晚(恒定失败的模型不再按固定节奏连发,
|
|
807
|
+
// 只会越打越稀)。失败 run 本就占用间隔(lastRunAt 在开跑时刷新,见 #89),
|
|
808
|
+
// 这里放大的是同一道闸,不新增任何状态面。间隔内的触发静默跳过:没有调用
|
|
809
|
+
// 发生,也就没有可审计的对象(与 #89 口径一致,不写审计行)。
|
|
810
|
+
if (minIntervalMs > 0 && now() - lastRunAt < effectiveMinIntervalMs()) return false;
|
|
772
811
|
const { trigger, count, chars } = shouldTrigger(service);
|
|
773
812
|
if (!trigger) return false;
|
|
774
|
-
|
|
813
|
+
// Issue #239(第 4 项,错峰队列)镜像到巩固:命中高峰就不调模型。与蒸馏的差别
|
|
814
|
+
// 在于巩固是**全局单实例**(蒸馏按会话各挂一个定时器),所以这里只需要一个
|
|
815
|
+
// deferTimer,且不需要 deferredRuns 那套按会话去重。
|
|
816
|
+
// baseline 刻意不刷新:阈值继续累积,留到非高峰一次性巩固(一次大 run 比多次
|
|
817
|
+
// 小 run 省)。审计只登记一行 skip——「为什么不再做梦了」必须对用户可观测。
|
|
818
|
+
if (isInPeakWindow(new Date(now()), peakHours)) {
|
|
819
|
+
try {
|
|
820
|
+
auditPeakSkip?.({ count, chars });
|
|
821
|
+
} catch (error) {
|
|
822
|
+
// 记账是 best-effort:写审计行失败只 warn,绝不反噬调度本身。
|
|
823
|
+
logger?.warn?.(`dsh-mneme dream: peak-hours audit failed: ${String(error)}`);
|
|
824
|
+
}
|
|
825
|
+
scheduleDeferredRun(service);
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
pendingTimer = setTimeoutFn(() => {
|
|
775
829
|
pendingTimer = null;
|
|
776
|
-
|
|
777
|
-
lastRunAt = Date.now();
|
|
778
|
-
// Defer the onRun invocation so a synchronous throw cannot escape the
|
|
779
|
-
// timer callback (which would crash the process) and skip the teardown.
|
|
780
|
-
// Errors are logged, never swallowed silently. inFlight lets dispose()
|
|
781
|
-
// await the running consolidation before the caller closes the store.
|
|
782
|
-
inFlight = Promise.resolve()
|
|
783
|
-
.then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
|
|
784
|
-
.then((result) => {
|
|
785
|
-
// Refresh the baseline only for a successful run (design §5.3: an
|
|
786
|
-
// LLM failure must not move the baseline, so the next write can
|
|
787
|
-
// immediately re-trigger a retry). A `{ok:false}` result or a throw
|
|
788
|
-
// keeps the old baseline. A run that reports nothing is treated as
|
|
789
|
-
// completed without failure (no-op hooks / minimal test doubles).
|
|
790
|
-
if (result && result.ok) {
|
|
791
|
-
try {
|
|
792
|
-
baseline = shouldTrigger(service);
|
|
793
|
-
} catch (error) {
|
|
794
|
-
// Store closed mid-flight: keep the last known baseline.
|
|
795
|
-
logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
})
|
|
799
|
-
.catch((error) => {
|
|
800
|
-
logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
|
|
801
|
-
// Failed runs do not refresh the baseline.
|
|
802
|
-
})
|
|
803
|
-
.finally(() => {
|
|
804
|
-
running = false;
|
|
805
|
-
inFlight = null;
|
|
806
|
-
});
|
|
830
|
+
startRun(service);
|
|
807
831
|
}, delayMs);
|
|
808
832
|
return true;
|
|
809
833
|
}
|
|
810
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Issue #239:高峰内择时补跑——挂到「距当前最近的一个高峰结束时刻」,被
|
|
837
|
+
* peakMaxDeferMinutes 截断时到点照跑(bypassPeak),长高峰不会把巩固饿死。
|
|
838
|
+
* 定时器 unref:不阻止宿主退出。重复触发不叠加(deferTimer 已在 maybeSchedule
|
|
839
|
+
* 的守卫里,这里再判一次以防从其它路径进来)。
|
|
840
|
+
*/
|
|
841
|
+
function scheduleDeferredRun(service) {
|
|
842
|
+
if (disposed || deferTimer) return;
|
|
843
|
+
const at = nextOffPeakAt(new Date(now()), peakHours);
|
|
844
|
+
if (!at) return;
|
|
845
|
+
const maxDeferMs = (peakMaxDeferMinutes ?? 0) * 60000;
|
|
846
|
+
let delay = Math.max(0, at.getTime() - now());
|
|
847
|
+
const capped = maxDeferMs > 0 && delay > maxDeferMs;
|
|
848
|
+
if (capped) delay = maxDeferMs;
|
|
849
|
+
deferTimer = setTimeoutFn(() => {
|
|
850
|
+
deferTimer = null;
|
|
851
|
+
if (disposed) return;
|
|
852
|
+
// 截断放行时仍在高峰:不再重新顺延(否则长高峰里会无限顺延,等于把巩固
|
|
853
|
+
// 关掉)。直接开跑,与蒸馏的 bypassPeak 同口径。
|
|
854
|
+
if (!capped && isInPeakWindow(new Date(now()), peakHours)) {
|
|
855
|
+
// 理论上到点已出高峰;时钟跳变/时段串被改小可能落回高峰内,此时再顺延一次。
|
|
856
|
+
scheduleDeferredRun(service);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
logger?.info?.(`dsh-mneme dream: peak-hours deferred run firing (capped=${capped}, delayMs=${delay})`);
|
|
860
|
+
startRun(service);
|
|
861
|
+
}, delay);
|
|
862
|
+
deferTimer.unref?.();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* 真正开跑。抽出来是因为两条路径都要用:写入触发的正常路径,与高峰顺延后的
|
|
867
|
+
* 补跑路径。onRun 的调用刻意放在 Promise 里——同步抛出的异常若逃出 timer 回调
|
|
868
|
+
* 会直接崩掉进程并跳过收尾。inFlight 让 dispose() 能等完这一轮再关库。
|
|
869
|
+
*/
|
|
870
|
+
function startRun(service) {
|
|
871
|
+
running = true;
|
|
872
|
+
lastRunAt = now();
|
|
873
|
+
inFlight = Promise.resolve()
|
|
874
|
+
.then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
|
|
875
|
+
.then((result) => {
|
|
876
|
+
// Refresh the baseline only for a successful run (design §5.3: an
|
|
877
|
+
// LLM failure must not move the baseline, so the next write can
|
|
878
|
+
// immediately re-trigger a retry). A `{ok:false}` result or a throw
|
|
879
|
+
// keeps the old baseline. A run that reports nothing is treated as
|
|
880
|
+
// completed without failure (no-op hooks / minimal test doubles).
|
|
881
|
+
if (result && result.ok) {
|
|
882
|
+
consecutiveFailures = 0; // Issue #292:成功清零,下次触发回到基数间隔
|
|
883
|
+
try {
|
|
884
|
+
baseline = shouldTrigger(service);
|
|
885
|
+
} catch (error) {
|
|
886
|
+
// Store closed mid-flight: keep the last known baseline.
|
|
887
|
+
logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
|
|
888
|
+
}
|
|
889
|
+
} else if (result) {
|
|
890
|
+
// Issue #292:ok:false(LLM 失败 / 空体 / 整单拒绝)计入连败;degraded
|
|
891
|
+
// 的 run 走 ok:true(LLM 本身成功了,只是决策被部分应用)→ 算成功、清零。
|
|
892
|
+
// 退避关闭时该计数没有消费者,行为与此前逐字节一致。
|
|
893
|
+
consecutiveFailures += 1;
|
|
894
|
+
}
|
|
895
|
+
// result 为空(no-op 桩 / 最小测试替身)=完成且无失败:基线与连败计数都不动。
|
|
896
|
+
})
|
|
897
|
+
.catch((error) => {
|
|
898
|
+
logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
|
|
899
|
+
// Failed runs do not refresh the baseline.
|
|
900
|
+
consecutiveFailures += 1; // Issue #292:抛错同样计入连败
|
|
901
|
+
})
|
|
902
|
+
.finally(() => {
|
|
903
|
+
running = false;
|
|
904
|
+
inFlight = null;
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
811
908
|
async function dispose() {
|
|
812
909
|
disposed = true;
|
|
813
|
-
if (pendingTimer) {
|
|
910
|
+
if (pendingTimer) { clearTimeoutFn(pendingTimer); pendingTimer = null; }
|
|
911
|
+
// Issue #239:高峰顺延定时器同样要清,否则进程关闭后仍会触发一次巩固。
|
|
912
|
+
if (deferTimer) { clearTimeoutFn(deferTimer); deferTimer = null; }
|
|
814
913
|
// An in-flight run is left to complete naturally (its LLM calls are
|
|
815
914
|
// already paid for and aborting would discard the work). Await it so the
|
|
816
915
|
// caller can close the store only after every write has landed.
|
package/src/guide.js
CHANGED
|
@@ -42,3 +42,21 @@ export const TOOL_GUIDE = {
|
|
|
42
42
|
" Save only durable, cross-session value (a preference, a decision with its rationale, an engineering constraint, " +
|
|
43
43
|
"a pitfall with its root cause). Trivial single-turn work does not belong here, and when unsure, do not save."
|
|
44
44
|
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* #249 N3 的降级路径:宿主若不提供可挂钩的压缩前时机,双落点里的「注入」那一半就没
|
|
48
|
+
* 有触发者。此时不把功能算作失败,而是把规则交给 agent 自判压力(§4.4 点名的降级
|
|
49
|
+
* 形态)。落点是工具描述——常驻文本、不进每轮上下文,零注入成本。
|
|
50
|
+
*
|
|
51
|
+
* 英文单一正本,理由同 TOOL_GUIDE(见文件头)。字段名用存储与注入那一侧的口径
|
|
52
|
+
* (`current_work` / `next_step` / open question,#249 §6.2);规格 §4.4 里的
|
|
53
|
+
* Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名,不是这里的。
|
|
54
|
+
*/
|
|
55
|
+
export const CONTINUITY_TOOL_RULE = {
|
|
56
|
+
memory_save:
|
|
57
|
+
" If this session's context is about to be compacted, first save a continuity note with this same tool " +
|
|
58
|
+
"(type: project, title: one stable title for this line of work, e.g. \"continuity: <topic>\"): current_work (what you " +
|
|
59
|
+
"are doing now), next_step, and any open question. Keep it to those fields, and reuse that same title for later " +
|
|
60
|
+
"updates: the store merges rows of the same type + title (the merge appends to the note instead of replacing it), " +
|
|
61
|
+
"so a fresh title each time just leaves a trail of near-duplicate project rows."
|
|
62
|
+
};
|