@modusensus/dsh-mneme 0.7.21 → 0.7.22
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.en.md +2 -0
- package/README.md +2 -0
- package/lib/config.js +12 -0
- package/lib/dream/decisions.js +105 -73
- package/lib/dream.js +23 -7
- package/lib/index.js +1 -0
- package/lib/settings.js +5 -1
- package/package.json +1 -1
- package/src/config.js +12 -0
- package/src/dream/decisions.js +105 -73
- package/src/dream.js +23 -7
- package/src/index.js +1 -0
- package/src/settings.js +5 -1
- package/test/api.test.js +8 -4
- package/test/dream.test.js +172 -0
package/README.en.md
CHANGED
|
@@ -166,6 +166,7 @@ Every **background LLM call** (autoDream consolidation + summary, autoSummarize
|
|
|
166
166
|
|
|
167
167
|
| Version | Highlights |
|
|
168
168
|
|------|------|
|
|
169
|
+
| **v0.7.22** | Restored the v0.6.9 skipInvalid tolerant-validation path (issue #89 regression, lost in the v0.7.11 rewrite): `dreamSkipInvalid` (default true) skips individual invalid decisions, applies the valid subset, and marks the run degraded; `allowCrossTypeMerge` (default false) explicitly relaxes cross-type merging — weak models (e.g. qwen3.8-flash) with jittery schema compliance no longer fail the whole batch and burn LLM calls. Strict mode and the sleep path behave unchanged; global caps/coverage floors still reject the whole run (running over cap = broken model, not minor schema drift). New `dreamMinIntervalMinutes` (0–10080, default 0 = unlimited) minimum autoDream trigger interval — failed/degraded runs also consume the interval (throttling exists to stop back-to-back failing calls); feature_flags whitelist now 34 keys; 696 tests green |
|
|
169
170
|
| **v0.7.21** | Fixed autoDream/sleep effort fallback being dead code on the stream path (the catch-based retry from v0.7.16 never fired): dsh-llm rc.1 turns adapter-stage failures (incl. `UNSUPPORTED_REASONING_EFFORT`) into a terminal error finish chunk instead of a throw; `streamText` now captures the finish-chunk failure cause (`describeStreamFailure` normalizes `{code,message}`) + `withEffortFallback` gains a `getStreamError` accessor (retries without effort when rejected) + `runAuditedLlm` supports `spec.streamError` (audit `error_message` carries the real cause; `run.error` stays a stable `"llm failed"`); 688 tests green |
|
|
170
171
|
| **v0.7.20** | Heat model restored (issue #87): v0.7.0 self-evolving memory back (`src/heat.js` power-law decay `H=1/(1+λΔt)^α` + per-type half-lives), sleep demotion dual protection (cold time-window + heat<0.05 + importance<5), touchRecalled gating back on `heatEnabled`, entity heat projection (ego node heat → size/opacity), recall_runs recording on by default; **default OFF** (matches v0.7.12 behavior out of the box) + feature_flags whitelist rollback switch + lightMode linkage + sleep demotion audit counts exposed (workbench can show "N demoted") + phase-two frontend (/list heat projection, HeatBadge three-tier badge, self-gated status heat-distribution card, order=heat page-local sort, all self-gated); better-sidebar fix (issue #88: soft integration moved to an inner dynamic sub-plugin, no more startup failure without bs); 685 tests green |
|
|
171
172
|
| **v0.7.18** | Ecosystem step one + query convergence: better-sidebar soft integration (inject declaration + optional peer `dsh-better-sidebar` + registerTab reusing the four views, safe skip when absent; narrow-container `@container` adaptation) + `/list?deposited=only` view (receipt_chain ∪ source=dream) + deposited/archived filter chips in the library + status-page dashboard (server totals + view-all jumps) + drawer restore for archived memories; 667 tests green |
|
|
@@ -219,6 +220,7 @@ Every **background LLM call** (autoDream consolidation + summary, autoSummarize
|
|
|
219
220
|
| v0.7.18 | ✅ Done | Ecosystem step one + query convergence | better-sidebar soft integration (inject declaration + optional peer + registerTab reusing the four views, safe skip when absent; narrow-container `@container`) + `/list?deposited=only` view + deposited/archived filter chips + status-page dashboard + drawer restore; 667 tests green |
|
|
220
221
|
| v0.7.20 | ✅ Done | Heat restore + phase-two frontend + better-sidebar fix | Heat model fully restored (issue #87, backported from v0.7.10: power-law decay + TYPE_DECAY + sleep heat-combined dual protection + entity heat projection) + acceptance checklist landed (heatEnabled default OFF / feature_flags 31 keys / lightMode linkage / sleep demotion audit / updated_at⊥last_accessed_at contract) + phase-two frontend (/list heat projection, HeatBadge three-tier, order=heat page-local sort) + better-sidebar fix (issue #88: inner dynamic sub-plugin); 685 tests green |
|
|
221
222
|
| v0.7.21 | ✅ Done | effort-fallback stream fix | Catch-based effort fallback was dead code on the stream path (dsh-llm rc.1 turns adapter failures into a terminal error finish chunk instead of a throw) → `streamText` captures the finish-chunk cause (`describeStreamFailure`) + `withEffortFallback` gains a `getStreamError` accessor (retries without effort when rejected) + `runAuditedLlm` supports `spec.streamError` (real cause in audit); 688 tests green |
|
|
223
|
+
| v0.7.22 | ✅ Done | skipInvalid tolerant-validation restore (issue #89) + autoDream throttle | Restored v0.6.9 skipInvalid dual-track structure (lost in the v0.7.11 rewrite): `dreamSkipInvalid` skips individual invalid decisions + applies the valid subset + marks the run degraded; `allowCrossTypeMerge` explicitly relaxes cross-type merging; weak models (qwen3.8-flash) with jittery schema compliance no longer fail the whole batch. New `dreamMinIntervalMinutes` (0–10080, default 0) minimum trigger interval — failed/degraded runs also consume it. Strict mode / sleep path unchanged; feature_flags whitelist 34 keys; 696 tests green |
|
|
222
224
|
| **v0.8.0** | 🚧 Planned (late Sep) | Graph enhancement | Interest-drift visualization + scope isolation (issue #17) + cross-workspace sharing |
|
|
223
225
|
|
|
224
226
|
> All new capabilities ship as **toggleable features** (enabled/disabled via configuration), conservatively on by default and never breaking existing behavior. The `failure_memories` table and the autoDream decision engine have already paved the way for future reflective growth.
|
package/README.md
CHANGED
|
@@ -207,6 +207,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
207
207
|
|
|
208
208
|
| 版本 | 亮点 |
|
|
209
209
|
|------|------|
|
|
210
|
+
| **v0.7.22** | 恢复 v0.6.9 的 skipInvalid 宽容校验路径(issue #89 回归,v0.7.11 重写丢失):`dreamSkipInvalid`(默认 true)单条非法决策跳过 + 合法子集应用 + run 记 degraded,`allowCrossTypeMerge`(默认 false)显式放宽跨类型合并——弱模型(如 qwen3.8-flash)决策合规抖动不再整单拒绝白烧 LLM 调用;严格模式/sleep 路径行为不变,全局上限/覆盖率下限仍整单拒绝(刷爆上限=模型坏了,非轻微 schema 漂移);新增 `dreamMinIntervalMinutes`(0-10080,默认 0=不限)autoDream 最小触发间隔,失败/degraded run 也占用间隔(节流防失败调用连发);feature_flags 白名单 34 键;696 测试全绿 |
|
|
210
211
|
| **v0.7.21** | 修复 autoDream/sleep 的 effort 回退在流式路径失效(v0.7.16 的 catch 式回退是死代码):dsh-llm rc.1 把 adapter 阶段异常(含 `UNSUPPORTED_REASONING_EFFORT`)转成终态 error finish chunk 不再抛出;`streamText` 现捕获 finish-chunk 失败原因(新增 `describeStreamFailure` 归一化 `{code,message}`)+ `withEffortFallback` 增加 `getStreamError` 访问器(effort 被拒时去掉重试一次)+ `runAuditedLlm` 支持 `spec.streamError`(audit 行 `error_message` 携带真实原因,`run.error` 稳定 `"llm failed"` 不变);688 测试全绿 |
|
|
211
212
|
| **v0.7.20** | heat 热度模型回归(issue #87):找回 v0.7.0 自进化记忆(`src/heat.js` 幂律衰减 `H=1/(1+λΔt)^α` + per-type 差异化半衰期)、sleep 降级热联合双保护(时间窗冷 + heat<0.05 + importance<5)、touchRecalled 门控改回 `heatEnabled`、实体热投影(ego 节点 heat → 前端大小/明暗)、recall_runs 默认记录;**默认关**(默认=与 v0.7.12 行为一致)+ feature_flags 白名单回滚开关 + lightMode 联动 + sleep 降级审计计数暴露(工作动态可展示"降级 N 条")+ 阶段二前端(/list heat 投影、HeatBadge 三档徽章、状态页热度分布卡、order=heat 页内排序,全部自门控);better-sidebar 修复(issue #88:软集成改内层动态子插件,无 bs 环境不再启动失败);685 测试全绿 |
|
|
212
213
|
| **v0.7.18** | 生态第一步 + 查询收敛:better-sidebar 软集成(inject 声明 + optional peer `dsh-better-sidebar` + registerTab 复用四视图,未装安全跳过;窄容器 `@container` 适配)+ `/list?deposited=only` 沉淀视图(receipt_chain ∪ source=dream)+ 记忆库沉淀/已归档筛选 chip + 状态页仪表盘化(统计 + 查看全部跳转预置筛选)+ 抽屉归档记忆「恢复」;667 测试全绿 |
|
|
@@ -291,6 +292,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
291
292
|
| **v0.7.18** | ✅ 完成 | 生态第一步 + 查询收敛 | better-sidebar 软集成(inject 声明 + optional peer `dsh-better-sidebar` + registerTab 复用四视图,未装安全跳过;窄容器 `@container` 适配)+ `/list?deposited=only` 沉淀视图(receipt_chain ∪ source=dream)+ 记忆库沉淀/已归档筛选 chip + 状态页仪表盘化(统计 + 查看全部跳转预置筛选)+ 抽屉归档记忆「恢复」;667 测试全绿 |
|
|
292
293
|
| **v0.7.20** | ✅ 完成 | heat 回归 + 阶段二前端 + better-sidebar 修复 | heat 热度模型完整找回(issue #87,v0.7.10 移植:幂律衰减 + TYPE_DECAY + sleep 热联合双保护 + 实体热投影)+ 验收清单落地(heatEnabled 默认关 / feature_flags 31 键 / lightMode 联动 / sleep 降级审计暴露 / updated_at⊥last_accessed_at 契约)+ 阶段二前端(/list heat 投影、HeatBadge 三档、order=heat 页内排序)+ better-sidebar 修复(issue #88:内层动态子插件);685 测试全绿 |
|
|
293
294
|
| **v0.7.21** | ✅ 完成 | effort 回退流式修复 | autoDream/sleep 的 catch 式 effort 回退在流式路径是死代码(dsh-llm rc.1 把 adapter 异常转成终态 error finish chunk 不再抛出)→ `streamText` 捕获 finish-chunk 失败原因(`describeStreamFailure` 归一化)+ `withEffortFallback` 增加 `getStreamError` 访问器(effort 被拒去重试)+ `runAuditedLlm` 支持 `spec.streamError`(audit 记真实原因);688 测试全绿 |
|
|
295
|
+
| **v0.7.22** | ✅ 完成 | skipInvalid 宽容校验回归(issue #89)+ autoDream 节流 | 恢复 v0.6.9 的 skipInvalid 双轨结构(v0.7.11 重写丢失):`dreamSkipInvalid` 单条非法决策跳过 + 合法子集应用 + run 记 degraded,`allowCrossTypeMerge` 显式放宽跨类型合并;弱模型(qwen3.8-flash)决策合规抖动不再整单拒绝;新增 `dreamMinIntervalMinutes`(0-10080,默认 0=不限)最小触发间隔,失败/degraded run 也占用间隔;严格模式/sleep 路径行为不变;feature_flags 白名单 34 键;696 测试全绿 |
|
|
294
296
|
| **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + scope 隔离(issue #17)+ 跨 workspace 记忆共享 |
|
|
295
297
|
|
|
296
298
|
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
|
package/lib/config.js
CHANGED
|
@@ -47,6 +47,11 @@ export const Config = z.object({
|
|
|
47
47
|
dreamThresholdCount: z.natural().min(1).max(1000).default(10),
|
|
48
48
|
dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
|
|
49
49
|
dreamDelayMs: z.natural().min(0).max(60000).default(2000),
|
|
50
|
+
// autoDream 触发最小间隔(分钟,0 = 不限制,Issue #89 请求 2):高频写入
|
|
51
|
+
// 场景下防止巩固调用(含失败重试)连发刷爆配额。间隔从每次实际开跑时刻
|
|
52
|
+
// 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
|
|
53
|
+
// 写入事件会重新评估。
|
|
54
|
+
dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
|
|
50
55
|
dreamProvider: z.string(),
|
|
51
56
|
dreamModel: z.string(),
|
|
52
57
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
@@ -74,6 +79,13 @@ export const Config = z.object({
|
|
|
74
79
|
// → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
|
|
75
80
|
// 默认 0.5(至少显式覆盖一半 snapshot)。
|
|
76
81
|
dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
|
|
82
|
+
// Issue #89:v0.6.9(Issue #26)的宽容路径回归。默认跳过单条非法决策
|
|
83
|
+
// (未知 id / 跨类型合并等)、应用合法子集、run 记 degraded;设 false 恢复
|
|
84
|
+
// 整单拒绝的严格模式。全局上限与覆盖率下限不受此开关影响、始终整单拒绝。
|
|
85
|
+
dreamSkipInvalid: z.boolean().default(true),
|
|
86
|
+
// 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合
|
|
87
|
+
// dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。
|
|
88
|
+
allowCrossTypeMerge: z.boolean().default(false),
|
|
77
89
|
// Rule version for dream adjudication: when this bumps, older dream_runs
|
|
78
90
|
// degrade to historical evidence (their receipts no longer drive live
|
|
79
91
|
// decisions). Default 0 = no versioning in use yet.
|
package/lib/dream/decisions.js
CHANGED
|
@@ -7,12 +7,22 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
-
* @param decisions - LLM-produced decision list.
|
|
10
|
+
* @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
|
|
11
|
+
* entries are spliced out of this array in place (the caller reuses the same
|
|
12
|
+
* reference downstream for apply/audit); implicit keeps are appended here too.
|
|
11
13
|
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
12
|
-
* @returns {{ok: boolean, errors: string[]}}
|
|
14
|
+
* @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>}}
|
|
13
15
|
*/
|
|
14
16
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
15
17
|
const errors = [];
|
|
18
|
+
// Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
|
|
19
|
+
// 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
|
|
20
|
+
// 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
|
|
21
|
+
// degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
|
|
22
|
+
// 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const survivors = [];
|
|
25
|
+
const skipInvalid = options.skipInvalid === true;
|
|
16
26
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
17
27
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
18
28
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
@@ -21,106 +31,122 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
21
31
|
const claimed = new Set();
|
|
22
32
|
for (const [index, d] of decisions.entries()) {
|
|
23
33
|
const at = `decision[${index}]`;
|
|
34
|
+
// 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
|
|
35
|
+
// 全局 errors(沿用 v0.6.9 的双轨结构)。
|
|
36
|
+
const local = [];
|
|
37
|
+
const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
|
|
24
38
|
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
29
|
-
if (d.action === "conflict") {
|
|
39
|
+
local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
40
|
+
} else if (d.action === "conflict") {
|
|
30
41
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
31
|
-
|
|
32
|
-
continue;
|
|
42
|
+
local.push(`${at}: conflict needs distinct winner and loser`);
|
|
33
43
|
}
|
|
34
44
|
} else if (d.action === "create") {
|
|
35
45
|
// Mint a fresh memory (sleep pattern discovery). Claims no existing id,
|
|
36
46
|
// so it skips the claiming loop below; evidence is optional provenance
|
|
37
47
|
// (already filtered to real ids by the caller) and is stored in content.
|
|
38
48
|
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
39
|
-
|
|
40
|
-
continue;
|
|
49
|
+
local.push(`${at}: create needs non-empty title`);
|
|
41
50
|
}
|
|
42
51
|
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
-
|
|
44
|
-
continue;
|
|
52
|
+
local.push(`${at}: create needs non-empty content`);
|
|
45
53
|
}
|
|
46
54
|
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
47
|
-
|
|
55
|
+
local.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
48
56
|
}
|
|
49
57
|
if (typeof d.type !== "string" || !d.type.trim()) {
|
|
50
|
-
|
|
58
|
+
local.push(`${at}: create needs non-empty type`);
|
|
51
59
|
}
|
|
52
|
-
continue;
|
|
53
60
|
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
54
|
-
|
|
55
|
-
continue;
|
|
61
|
+
local.push(`${at}: ${d.action} needs non-empty ids`);
|
|
56
62
|
}
|
|
57
63
|
// update-specific field validation runs BEFORE claiming ids, so a failing
|
|
58
64
|
// update never pollutes the claimed set (which drives the "every id must
|
|
59
65
|
// appear in a decision" check below).
|
|
60
|
-
if (d
|
|
66
|
+
if (local.length === 0 && d?.action === "update") {
|
|
61
67
|
// 只能更新单条
|
|
62
68
|
if (!Array.isArray(d.ids) || d.ids.length !== 1) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (ageHours < minAgeHours) {
|
|
83
|
-
errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
84
|
-
continue;
|
|
69
|
+
local.push(`${at}: update must target exactly one id`);
|
|
70
|
+
} else {
|
|
71
|
+
// 必须产生实际变化
|
|
72
|
+
const mem = snapshot.get(d.ids[0]);
|
|
73
|
+
const hasChange = (d.title !== undefined && d.title !== mem?.title)
|
|
74
|
+
|| (d.content !== undefined && d.content !== mem?.content)
|
|
75
|
+
|| (d.importance !== undefined && d.importance !== mem?.importance);
|
|
76
|
+
if (!hasChange) {
|
|
77
|
+
local.push(`${at}: update must change at least one field`);
|
|
78
|
+
} else if (mem?.type === "summary") {
|
|
79
|
+
// 不能更新 summary
|
|
80
|
+
local.push(`${at}: cannot update summary via update action`);
|
|
81
|
+
} else {
|
|
82
|
+
// 保护期:新建记忆不可立即被 update(可配置)
|
|
83
|
+
const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
|
|
84
|
+
if (ageHours < minAgeHours) {
|
|
85
|
+
local.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
if (local.length === 0 && d && d.action !== "create") {
|
|
91
|
+
// seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
|
|
92
|
+
// (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
const mem = snapshot.get(id);
|
|
96
|
+
if (!mem) {
|
|
97
|
+
local.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
98
|
+
} else if (mem.archived || mem.type === "summary") {
|
|
99
|
+
local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
100
|
+
}
|
|
101
|
+
if (claimed.has(id) || seen.has(id)) {
|
|
102
|
+
local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
103
|
+
}
|
|
104
|
+
seen.add(id);
|
|
93
105
|
}
|
|
94
|
-
if (
|
|
95
|
-
|
|
106
|
+
if (local.length === 0 && d.action === "merge") {
|
|
107
|
+
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
108
|
+
local.push(`${at}: merge keepSource must be one of ids`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
111
|
+
local.push(`${at}: merge needs non-empty title and content`);
|
|
112
|
+
}
|
|
113
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
114
|
+
local.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
115
|
+
}
|
|
116
|
+
// Merging across types would blur preference/project/decision boundaries
|
|
117
|
+
// in the injected context; the snapshot carries each entry's type.
|
|
118
|
+
// Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
|
|
119
|
+
// 后放宽该检查,类型边界由用户自行承担。
|
|
120
|
+
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
121
|
+
if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
|
|
122
|
+
local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
123
|
+
}
|
|
96
124
|
}
|
|
97
|
-
claimed.add(id);
|
|
98
125
|
}
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
108
|
-
}
|
|
109
|
-
// Merging across types would blur preference/project/decision boundaries
|
|
110
|
-
// in the injected context; the snapshot carries each entry's type.
|
|
111
|
-
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
112
|
-
if (mergeTypes.size > 1) {
|
|
113
|
-
errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
126
|
+
if (local.length > 0) {
|
|
127
|
+
if (skipInvalid) {
|
|
128
|
+
// 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
|
|
129
|
+
// keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
|
|
130
|
+
// errors,否则会误触发下方的整单拒绝。
|
|
131
|
+
skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
|
|
132
|
+
} else {
|
|
133
|
+
errors.push(...local);
|
|
114
134
|
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (d.action !== "create") {
|
|
138
|
+
for (const id of ids) claimed.add(id);
|
|
115
139
|
}
|
|
140
|
+
survivors.push(d);
|
|
116
141
|
}
|
|
117
|
-
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
-
|
|
142
|
+
// Cap update churn: too many edits in one cycle signals a runaway model.
|
|
143
|
+
// 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
|
|
144
|
+
const updateCount = survivors.filter((d) => d.action === "update").length;
|
|
119
145
|
if (updateCount > maxUpdatePerRun) {
|
|
120
146
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
121
147
|
}
|
|
122
148
|
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
123
|
-
const createCount =
|
|
149
|
+
const createCount = survivors.filter((d) => d.action === "create").length;
|
|
124
150
|
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
125
151
|
if (createCount > maxCreatePerRun) {
|
|
126
152
|
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
@@ -137,25 +163,31 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
137
163
|
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
138
164
|
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
139
165
|
if (errors.length > 0) {
|
|
140
|
-
return { ok: false, errors };
|
|
166
|
+
return { ok: false, errors, skipped };
|
|
141
167
|
}
|
|
142
168
|
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
143
169
|
if (options.dreamImplicitKeep !== false) {
|
|
144
170
|
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
145
171
|
if (coverage < minCoverage) {
|
|
146
172
|
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
147
|
-
return { ok: false, errors };
|
|
173
|
+
return { ok: false, errors, skipped };
|
|
148
174
|
}
|
|
149
175
|
for (const id of snapshot.keys()) {
|
|
150
|
-
if (!claimed.has(id))
|
|
176
|
+
if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
|
|
151
177
|
}
|
|
152
178
|
} else {
|
|
153
179
|
for (const id of snapshot.keys()) {
|
|
154
180
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
155
181
|
}
|
|
156
|
-
if (errors.length > 0) return { ok: false, errors };
|
|
182
|
+
if (errors.length > 0) return { ok: false, errors, skipped };
|
|
183
|
+
}
|
|
184
|
+
// 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
|
|
185
|
+
// 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
|
|
186
|
+
// 内容一致时(无跳过、无补齐)为 no-op。
|
|
187
|
+
if (survivors.length !== decisions.length) {
|
|
188
|
+
decisions.splice(0, decisions.length, ...survivors);
|
|
157
189
|
}
|
|
158
|
-
return { ok: true, errors };
|
|
190
|
+
return { ok: true, errors, skipped };
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
/** Marker thrown when a decision target changed since the run snapshot. */
|
package/lib/dream.js
CHANGED
|
@@ -468,12 +468,15 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
|
|
|
468
468
|
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger, semantic = null }) {
|
|
471
|
+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
|
|
472
472
|
let pendingTimer = null;
|
|
473
473
|
let running = false;
|
|
474
474
|
let disposed = false;
|
|
475
475
|
let baseline = { count: 0, chars: 0 };
|
|
476
476
|
let inFlight = null;
|
|
477
|
+
// Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
|
|
478
|
+
// 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
|
|
479
|
+
let lastRunAt = 0;
|
|
477
480
|
|
|
478
481
|
function shouldTrigger(service) {
|
|
479
482
|
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
@@ -486,11 +489,14 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
486
489
|
|
|
487
490
|
function maybeSchedule(service) {
|
|
488
491
|
if (disposed || running || pendingTimer) return false;
|
|
492
|
+
// Issue #89(请求 2):最小触发间隔闸门。
|
|
493
|
+
if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
|
|
489
494
|
const { trigger, count, chars } = shouldTrigger(service);
|
|
490
495
|
if (!trigger) return false;
|
|
491
496
|
pendingTimer = setTimeout(() => {
|
|
492
497
|
pendingTimer = null;
|
|
493
498
|
running = true;
|
|
499
|
+
lastRunAt = Date.now();
|
|
494
500
|
// Defer the onRun invocation so a synchronous throw cannot escape the
|
|
495
501
|
// timer callback (which would crash the process) and skip the teardown.
|
|
496
502
|
// Errors are logged, never swallowed silently. inFlight lets dispose()
|
|
@@ -704,18 +710,26 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
704
710
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
|
|
705
711
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
|
706
712
|
}
|
|
707
|
-
const { ok, errors } = validateDecisions(decisions, snapshot, {
|
|
713
|
+
const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
|
|
708
714
|
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
709
715
|
minAgeHours: config.reflectionUpdateMinAgeHours,
|
|
710
716
|
// v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
|
|
711
717
|
// 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
|
|
712
718
|
dreamImplicitKeep: config.dreamImplicitKeep,
|
|
713
|
-
dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
|
|
719
|
+
dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
|
|
720
|
+
// Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
|
|
721
|
+
// 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
|
|
722
|
+
skipInvalid: config.dreamSkipInvalid !== false,
|
|
723
|
+
allowCrossTypeMerge: config.allowCrossTypeMerge === true
|
|
714
724
|
});
|
|
715
725
|
if (!ok) {
|
|
716
726
|
logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
|
|
717
727
|
return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
|
|
718
728
|
}
|
|
729
|
+
const skippedInvalid = skipped.length > 0;
|
|
730
|
+
if (skippedInvalid) {
|
|
731
|
+
logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped (run degrades): ${skipped.map((s) => s.error).join("; ")}`);
|
|
732
|
+
}
|
|
719
733
|
|
|
720
734
|
// Capture pre-update snapshots so the audit records what each update changed.
|
|
721
735
|
const updateSnapshots = {};
|
|
@@ -872,9 +886,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
872
886
|
// run. ok:false keeps the scheduler from moving the baseline.
|
|
873
887
|
// ok — either real changes landed, or a fresh summary was stored
|
|
874
888
|
// (all-keep + summary is a substantive summary refresh).
|
|
875
|
-
// degraded — real consolidation landed but the
|
|
876
|
-
//
|
|
877
|
-
//
|
|
889
|
+
// degraded — real consolidation landed but the run did not produce its
|
|
890
|
+
// full output: the summary came back empty/missing, or
|
|
891
|
+
// skipInvalid dropped individually-invalid decisions
|
|
892
|
+
// (Issue #89 — marked, not faked). The valid subset was
|
|
893
|
+
// absorbed (ok for the baseline).
|
|
878
894
|
let status;
|
|
879
895
|
let okResult;
|
|
880
896
|
if (partial) {
|
|
@@ -884,7 +900,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
884
900
|
status = summaryStored ? "ok" : "noop";
|
|
885
901
|
okResult = summaryStored;
|
|
886
902
|
} else {
|
|
887
|
-
status = summaryStored ? "ok" : "degraded";
|
|
903
|
+
status = summaryStored && !skippedInvalid ? "ok" : "degraded";
|
|
888
904
|
okResult = true;
|
|
889
905
|
}
|
|
890
906
|
return finish({
|
package/lib/index.js
CHANGED
|
@@ -283,6 +283,7 @@ export const apply = (ctx, config) => {
|
|
|
283
283
|
thresholdCount: cfg.dreamThresholdCount,
|
|
284
284
|
thresholdChars: cfg.dreamThresholdChars,
|
|
285
285
|
delayMs: cfg.dreamDelayMs,
|
|
286
|
+
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
|
|
286
287
|
logger: ctx.logger,
|
|
287
288
|
semantic: { embedder, vectorIndex },
|
|
288
289
|
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
|
package/lib/settings.js
CHANGED
|
@@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
58
58
|
"bm25SearchEnabled",
|
|
59
59
|
"conflictFreezeEnabled",
|
|
60
60
|
"trustEpistemicWeighting",
|
|
61
|
+
// Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
|
|
62
|
+
"dreamSkipInvalid",
|
|
63
|
+
"allowCrossTypeMerge",
|
|
61
64
|
// 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
|
|
62
65
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
63
66
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
@@ -70,7 +73,8 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
70
73
|
distillRateLimitRetries: [0, 10],
|
|
71
74
|
distillRateLimitBaseDelayMs: [100, 60000],
|
|
72
75
|
distillMaxChars: [1000, 200000],
|
|
73
|
-
codingBoostFactor: [1, 5]
|
|
76
|
+
codingBoostFactor: [1, 5],
|
|
77
|
+
dreamMinIntervalMinutes: [0, 10080]
|
|
74
78
|
};
|
|
75
79
|
// 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
|
|
76
80
|
// 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
|
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, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.22",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/config.js
CHANGED
|
@@ -47,6 +47,11 @@ export const Config = z.object({
|
|
|
47
47
|
dreamThresholdCount: z.natural().min(1).max(1000).default(10),
|
|
48
48
|
dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
|
|
49
49
|
dreamDelayMs: z.natural().min(0).max(60000).default(2000),
|
|
50
|
+
// autoDream 触发最小间隔(分钟,0 = 不限制,Issue #89 请求 2):高频写入
|
|
51
|
+
// 场景下防止巩固调用(含失败重试)连发刷爆配额。间隔从每次实际开跑时刻
|
|
52
|
+
// 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
|
|
53
|
+
// 写入事件会重新评估。
|
|
54
|
+
dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
|
|
50
55
|
dreamProvider: z.string(),
|
|
51
56
|
dreamModel: z.string(),
|
|
52
57
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
@@ -74,6 +79,13 @@ export const Config = z.object({
|
|
|
74
79
|
// → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
|
|
75
80
|
// 默认 0.5(至少显式覆盖一半 snapshot)。
|
|
76
81
|
dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
|
|
82
|
+
// Issue #89:v0.6.9(Issue #26)的宽容路径回归。默认跳过单条非法决策
|
|
83
|
+
// (未知 id / 跨类型合并等)、应用合法子集、run 记 degraded;设 false 恢复
|
|
84
|
+
// 整单拒绝的严格模式。全局上限与覆盖率下限不受此开关影响、始终整单拒绝。
|
|
85
|
+
dreamSkipInvalid: z.boolean().default(true),
|
|
86
|
+
// 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合
|
|
87
|
+
// dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。
|
|
88
|
+
allowCrossTypeMerge: z.boolean().default(false),
|
|
77
89
|
// Rule version for dream adjudication: when this bumps, older dream_runs
|
|
78
90
|
// degrade to historical evidence (their receipts no longer drive live
|
|
79
91
|
// decisions). Default 0 = no versioning in use yet.
|
package/src/dream/decisions.js
CHANGED
|
@@ -7,12 +7,22 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
-
* @param decisions - LLM-produced decision list.
|
|
10
|
+
* @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
|
|
11
|
+
* entries are spliced out of this array in place (the caller reuses the same
|
|
12
|
+
* reference downstream for apply/audit); implicit keeps are appended here too.
|
|
11
13
|
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
12
|
-
* @returns {{ok: boolean, errors: string[]}}
|
|
14
|
+
* @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>}}
|
|
13
15
|
*/
|
|
14
16
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
15
17
|
const errors = [];
|
|
18
|
+
// Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
|
|
19
|
+
// 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
|
|
20
|
+
// 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
|
|
21
|
+
// degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
|
|
22
|
+
// 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const survivors = [];
|
|
25
|
+
const skipInvalid = options.skipInvalid === true;
|
|
16
26
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
17
27
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
18
28
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
@@ -21,106 +31,122 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
21
31
|
const claimed = new Set();
|
|
22
32
|
for (const [index, d] of decisions.entries()) {
|
|
23
33
|
const at = `decision[${index}]`;
|
|
34
|
+
// 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
|
|
35
|
+
// 全局 errors(沿用 v0.6.9 的双轨结构)。
|
|
36
|
+
const local = [];
|
|
37
|
+
const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
|
|
24
38
|
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
29
|
-
if (d.action === "conflict") {
|
|
39
|
+
local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
40
|
+
} else if (d.action === "conflict") {
|
|
30
41
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
31
|
-
|
|
32
|
-
continue;
|
|
42
|
+
local.push(`${at}: conflict needs distinct winner and loser`);
|
|
33
43
|
}
|
|
34
44
|
} else if (d.action === "create") {
|
|
35
45
|
// Mint a fresh memory (sleep pattern discovery). Claims no existing id,
|
|
36
46
|
// so it skips the claiming loop below; evidence is optional provenance
|
|
37
47
|
// (already filtered to real ids by the caller) and is stored in content.
|
|
38
48
|
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
39
|
-
|
|
40
|
-
continue;
|
|
49
|
+
local.push(`${at}: create needs non-empty title`);
|
|
41
50
|
}
|
|
42
51
|
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
-
|
|
44
|
-
continue;
|
|
52
|
+
local.push(`${at}: create needs non-empty content`);
|
|
45
53
|
}
|
|
46
54
|
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
47
|
-
|
|
55
|
+
local.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
48
56
|
}
|
|
49
57
|
if (typeof d.type !== "string" || !d.type.trim()) {
|
|
50
|
-
|
|
58
|
+
local.push(`${at}: create needs non-empty type`);
|
|
51
59
|
}
|
|
52
|
-
continue;
|
|
53
60
|
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
54
|
-
|
|
55
|
-
continue;
|
|
61
|
+
local.push(`${at}: ${d.action} needs non-empty ids`);
|
|
56
62
|
}
|
|
57
63
|
// update-specific field validation runs BEFORE claiming ids, so a failing
|
|
58
64
|
// update never pollutes the claimed set (which drives the "every id must
|
|
59
65
|
// appear in a decision" check below).
|
|
60
|
-
if (d
|
|
66
|
+
if (local.length === 0 && d?.action === "update") {
|
|
61
67
|
// 只能更新单条
|
|
62
68
|
if (!Array.isArray(d.ids) || d.ids.length !== 1) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (ageHours < minAgeHours) {
|
|
83
|
-
errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
84
|
-
continue;
|
|
69
|
+
local.push(`${at}: update must target exactly one id`);
|
|
70
|
+
} else {
|
|
71
|
+
// 必须产生实际变化
|
|
72
|
+
const mem = snapshot.get(d.ids[0]);
|
|
73
|
+
const hasChange = (d.title !== undefined && d.title !== mem?.title)
|
|
74
|
+
|| (d.content !== undefined && d.content !== mem?.content)
|
|
75
|
+
|| (d.importance !== undefined && d.importance !== mem?.importance);
|
|
76
|
+
if (!hasChange) {
|
|
77
|
+
local.push(`${at}: update must change at least one field`);
|
|
78
|
+
} else if (mem?.type === "summary") {
|
|
79
|
+
// 不能更新 summary
|
|
80
|
+
local.push(`${at}: cannot update summary via update action`);
|
|
81
|
+
} else {
|
|
82
|
+
// 保护期:新建记忆不可立即被 update(可配置)
|
|
83
|
+
const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
|
|
84
|
+
if (ageHours < minAgeHours) {
|
|
85
|
+
local.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
if (local.length === 0 && d && d.action !== "create") {
|
|
91
|
+
// seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
|
|
92
|
+
// (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
const mem = snapshot.get(id);
|
|
96
|
+
if (!mem) {
|
|
97
|
+
local.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
98
|
+
} else if (mem.archived || mem.type === "summary") {
|
|
99
|
+
local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
100
|
+
}
|
|
101
|
+
if (claimed.has(id) || seen.has(id)) {
|
|
102
|
+
local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
103
|
+
}
|
|
104
|
+
seen.add(id);
|
|
93
105
|
}
|
|
94
|
-
if (
|
|
95
|
-
|
|
106
|
+
if (local.length === 0 && d.action === "merge") {
|
|
107
|
+
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
108
|
+
local.push(`${at}: merge keepSource must be one of ids`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
111
|
+
local.push(`${at}: merge needs non-empty title and content`);
|
|
112
|
+
}
|
|
113
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
114
|
+
local.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
115
|
+
}
|
|
116
|
+
// Merging across types would blur preference/project/decision boundaries
|
|
117
|
+
// in the injected context; the snapshot carries each entry's type.
|
|
118
|
+
// Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
|
|
119
|
+
// 后放宽该检查,类型边界由用户自行承担。
|
|
120
|
+
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
121
|
+
if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
|
|
122
|
+
local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
123
|
+
}
|
|
96
124
|
}
|
|
97
|
-
claimed.add(id);
|
|
98
125
|
}
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
108
|
-
}
|
|
109
|
-
// Merging across types would blur preference/project/decision boundaries
|
|
110
|
-
// in the injected context; the snapshot carries each entry's type.
|
|
111
|
-
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
112
|
-
if (mergeTypes.size > 1) {
|
|
113
|
-
errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
126
|
+
if (local.length > 0) {
|
|
127
|
+
if (skipInvalid) {
|
|
128
|
+
// 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
|
|
129
|
+
// keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
|
|
130
|
+
// errors,否则会误触发下方的整单拒绝。
|
|
131
|
+
skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
|
|
132
|
+
} else {
|
|
133
|
+
errors.push(...local);
|
|
114
134
|
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (d.action !== "create") {
|
|
138
|
+
for (const id of ids) claimed.add(id);
|
|
115
139
|
}
|
|
140
|
+
survivors.push(d);
|
|
116
141
|
}
|
|
117
|
-
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
-
|
|
142
|
+
// Cap update churn: too many edits in one cycle signals a runaway model.
|
|
143
|
+
// 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
|
|
144
|
+
const updateCount = survivors.filter((d) => d.action === "update").length;
|
|
119
145
|
if (updateCount > maxUpdatePerRun) {
|
|
120
146
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
121
147
|
}
|
|
122
148
|
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
123
|
-
const createCount =
|
|
149
|
+
const createCount = survivors.filter((d) => d.action === "create").length;
|
|
124
150
|
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
125
151
|
if (createCount > maxCreatePerRun) {
|
|
126
152
|
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
@@ -137,25 +163,31 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
137
163
|
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
138
164
|
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
139
165
|
if (errors.length > 0) {
|
|
140
|
-
return { ok: false, errors };
|
|
166
|
+
return { ok: false, errors, skipped };
|
|
141
167
|
}
|
|
142
168
|
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
143
169
|
if (options.dreamImplicitKeep !== false) {
|
|
144
170
|
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
145
171
|
if (coverage < minCoverage) {
|
|
146
172
|
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
147
|
-
return { ok: false, errors };
|
|
173
|
+
return { ok: false, errors, skipped };
|
|
148
174
|
}
|
|
149
175
|
for (const id of snapshot.keys()) {
|
|
150
|
-
if (!claimed.has(id))
|
|
176
|
+
if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
|
|
151
177
|
}
|
|
152
178
|
} else {
|
|
153
179
|
for (const id of snapshot.keys()) {
|
|
154
180
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
155
181
|
}
|
|
156
|
-
if (errors.length > 0) return { ok: false, errors };
|
|
182
|
+
if (errors.length > 0) return { ok: false, errors, skipped };
|
|
183
|
+
}
|
|
184
|
+
// 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
|
|
185
|
+
// 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
|
|
186
|
+
// 内容一致时(无跳过、无补齐)为 no-op。
|
|
187
|
+
if (survivors.length !== decisions.length) {
|
|
188
|
+
decisions.splice(0, decisions.length, ...survivors);
|
|
157
189
|
}
|
|
158
|
-
return { ok: true, errors };
|
|
190
|
+
return { ok: true, errors, skipped };
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
/** Marker thrown when a decision target changed since the run snapshot. */
|
package/src/dream.js
CHANGED
|
@@ -468,12 +468,15 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
|
|
|
468
468
|
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger, semantic = null }) {
|
|
471
|
+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
|
|
472
472
|
let pendingTimer = null;
|
|
473
473
|
let running = false;
|
|
474
474
|
let disposed = false;
|
|
475
475
|
let baseline = { count: 0, chars: 0 };
|
|
476
476
|
let inFlight = null;
|
|
477
|
+
// Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
|
|
478
|
+
// 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
|
|
479
|
+
let lastRunAt = 0;
|
|
477
480
|
|
|
478
481
|
function shouldTrigger(service) {
|
|
479
482
|
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
@@ -486,11 +489,14 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
486
489
|
|
|
487
490
|
function maybeSchedule(service) {
|
|
488
491
|
if (disposed || running || pendingTimer) return false;
|
|
492
|
+
// Issue #89(请求 2):最小触发间隔闸门。
|
|
493
|
+
if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
|
|
489
494
|
const { trigger, count, chars } = shouldTrigger(service);
|
|
490
495
|
if (!trigger) return false;
|
|
491
496
|
pendingTimer = setTimeout(() => {
|
|
492
497
|
pendingTimer = null;
|
|
493
498
|
running = true;
|
|
499
|
+
lastRunAt = Date.now();
|
|
494
500
|
// Defer the onRun invocation so a synchronous throw cannot escape the
|
|
495
501
|
// timer callback (which would crash the process) and skip the teardown.
|
|
496
502
|
// Errors are logged, never swallowed silently. inFlight lets dispose()
|
|
@@ -704,18 +710,26 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
704
710
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
|
|
705
711
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
|
706
712
|
}
|
|
707
|
-
const { ok, errors } = validateDecisions(decisions, snapshot, {
|
|
713
|
+
const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
|
|
708
714
|
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
709
715
|
minAgeHours: config.reflectionUpdateMinAgeHours,
|
|
710
716
|
// v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
|
|
711
717
|
// 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
|
|
712
718
|
dreamImplicitKeep: config.dreamImplicitKeep,
|
|
713
|
-
dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
|
|
719
|
+
dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
|
|
720
|
+
// Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
|
|
721
|
+
// 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
|
|
722
|
+
skipInvalid: config.dreamSkipInvalid !== false,
|
|
723
|
+
allowCrossTypeMerge: config.allowCrossTypeMerge === true
|
|
714
724
|
});
|
|
715
725
|
if (!ok) {
|
|
716
726
|
logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
|
|
717
727
|
return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
|
|
718
728
|
}
|
|
729
|
+
const skippedInvalid = skipped.length > 0;
|
|
730
|
+
if (skippedInvalid) {
|
|
731
|
+
logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped (run degrades): ${skipped.map((s) => s.error).join("; ")}`);
|
|
732
|
+
}
|
|
719
733
|
|
|
720
734
|
// Capture pre-update snapshots so the audit records what each update changed.
|
|
721
735
|
const updateSnapshots = {};
|
|
@@ -872,9 +886,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
872
886
|
// run. ok:false keeps the scheduler from moving the baseline.
|
|
873
887
|
// ok — either real changes landed, or a fresh summary was stored
|
|
874
888
|
// (all-keep + summary is a substantive summary refresh).
|
|
875
|
-
// degraded — real consolidation landed but the
|
|
876
|
-
//
|
|
877
|
-
//
|
|
889
|
+
// degraded — real consolidation landed but the run did not produce its
|
|
890
|
+
// full output: the summary came back empty/missing, or
|
|
891
|
+
// skipInvalid dropped individually-invalid decisions
|
|
892
|
+
// (Issue #89 — marked, not faked). The valid subset was
|
|
893
|
+
// absorbed (ok for the baseline).
|
|
878
894
|
let status;
|
|
879
895
|
let okResult;
|
|
880
896
|
if (partial) {
|
|
@@ -884,7 +900,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
884
900
|
status = summaryStored ? "ok" : "noop";
|
|
885
901
|
okResult = summaryStored;
|
|
886
902
|
} else {
|
|
887
|
-
status = summaryStored ? "ok" : "degraded";
|
|
903
|
+
status = summaryStored && !skippedInvalid ? "ok" : "degraded";
|
|
888
904
|
okResult = true;
|
|
889
905
|
}
|
|
890
906
|
return finish({
|
package/src/index.js
CHANGED
|
@@ -283,6 +283,7 @@ export const apply = (ctx, config) => {
|
|
|
283
283
|
thresholdCount: cfg.dreamThresholdCount,
|
|
284
284
|
thresholdChars: cfg.dreamThresholdChars,
|
|
285
285
|
delayMs: cfg.dreamDelayMs,
|
|
286
|
+
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
|
|
286
287
|
logger: ctx.logger,
|
|
287
288
|
semantic: { embedder, vectorIndex },
|
|
288
289
|
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
|
package/src/settings.js
CHANGED
|
@@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
58
58
|
"bm25SearchEnabled",
|
|
59
59
|
"conflictFreezeEnabled",
|
|
60
60
|
"trustEpistemicWeighting",
|
|
61
|
+
// Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
|
|
62
|
+
"dreamSkipInvalid",
|
|
63
|
+
"allowCrossTypeMerge",
|
|
61
64
|
// 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
|
|
62
65
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
63
66
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
@@ -70,7 +73,8 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
70
73
|
distillRateLimitRetries: [0, 10],
|
|
71
74
|
distillRateLimitBaseDelayMs: [100, 60000],
|
|
72
75
|
distillMaxChars: [1000, 200000],
|
|
73
|
-
codingBoostFactor: [1, 5]
|
|
76
|
+
codingBoostFactor: [1, 5],
|
|
77
|
+
dreamMinIntervalMinutes: [0, 10080]
|
|
74
78
|
};
|
|
75
79
|
// 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
|
|
76
80
|
// 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
|
package/test/api.test.js
CHANGED
|
@@ -527,10 +527,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
527
527
|
assert.equal(res.statusCode, 200);
|
|
528
528
|
const data = JSON.parse(res.body);
|
|
529
529
|
assert.deepEqual(data.overrides, {});
|
|
530
|
-
// effective 覆盖全部
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
530
|
+
// effective 覆盖全部 34 个白名单键(含 v0.7.20 heatEnabled 与 Issue #89 回归
|
|
531
|
+
// 修复新增的 dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes),
|
|
532
|
+
// 未覆盖时取 bundle 配置的解析默认值;dreamProvider/dreamModel 无 schema
|
|
533
|
+
// 默认值(Config({}) 解析为 undefined),不编造给前端 → 34 - 2 = 32
|
|
534
|
+
assert.equal(Object.keys(data.effective).length, 32);
|
|
535
|
+
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
536
|
+
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
537
|
+
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
534
538
|
assert.equal(data.effective.autoInject, true);
|
|
535
539
|
assert.equal(data.effective.codingRetrospect, false);
|
|
536
540
|
assert.equal(data.effective.distillMaxChars, 24000);
|
package/test/dream.test.js
CHANGED
|
@@ -899,3 +899,175 @@ test("Bug8: a failed LLM call is recorded with status=error and does not block t
|
|
|
899
899
|
assert.ok(rows[0].error_message, "error message recorded");
|
|
900
900
|
store.close();
|
|
901
901
|
});
|
|
902
|
+
|
|
903
|
+
// --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
|
|
904
|
+
// 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
|
|
905
|
+
// 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
|
|
906
|
+
|
|
907
|
+
test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
|
|
908
|
+
const snap = new Map([
|
|
909
|
+
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
910
|
+
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
911
|
+
["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
|
|
912
|
+
["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
|
|
913
|
+
]);
|
|
914
|
+
const decisions = [
|
|
915
|
+
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
|
|
916
|
+
{ action: "archive", ids: ["a"], reason: "stale" },
|
|
917
|
+
{ action: "archive", ids: ["b"], reason: "stale" }
|
|
918
|
+
];
|
|
919
|
+
const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
920
|
+
assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
|
|
921
|
+
assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
|
|
922
|
+
assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
|
|
923
|
+
assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
|
|
924
|
+
// invalid merge spliced out of the caller's array; valid archives + implicit
|
|
925
|
+
// keeps for p/j survive (p/j were left unclaimed by the skipped merge)
|
|
926
|
+
assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
|
|
927
|
+
assert.deepEqual(decisions[0].ids, ["a"]);
|
|
928
|
+
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
|
|
929
|
+
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("validateDecisions skipInvalid: an all-invalid batch still rejects (coverage floor guards truncation)", () => {
|
|
933
|
+
const snap = new Map([
|
|
934
|
+
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
935
|
+
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
936
|
+
["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
|
|
937
|
+
["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
|
|
938
|
+
]);
|
|
939
|
+
// both decisions are cross-type merges → both skipped → valid claims = 0
|
|
940
|
+
const decisions = [
|
|
941
|
+
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
|
|
942
|
+
{ action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
|
|
943
|
+
];
|
|
944
|
+
const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
945
|
+
assert.equal(ok, false, "no valid decisions left → whole batch rejected");
|
|
946
|
+
assert.equal(skipped.length, 2);
|
|
947
|
+
assert.ok(errors.some((e) => e.includes("coverage")), "coverage error present");
|
|
948
|
+
assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
|
|
952
|
+
const snap = new Map([
|
|
953
|
+
["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
|
|
954
|
+
["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
|
|
955
|
+
["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
|
|
956
|
+
]);
|
|
957
|
+
const decisions = [
|
|
958
|
+
{ action: "update", ids: ["a"], content: "新A" },
|
|
959
|
+
{ action: "update", ids: ["b"], content: "新B" },
|
|
960
|
+
{ action: "update", ids: ["c"], content: "新C" }
|
|
961
|
+
];
|
|
962
|
+
const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
963
|
+
assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
|
|
964
|
+
assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
|
|
968
|
+
const snap = new Map([
|
|
969
|
+
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
970
|
+
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
|
|
971
|
+
]);
|
|
972
|
+
const decisions = [
|
|
973
|
+
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
|
|
974
|
+
];
|
|
975
|
+
const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
|
|
976
|
+
assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
|
|
977
|
+
assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
|
|
981
|
+
const snap = new Map([
|
|
982
|
+
["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
|
|
983
|
+
["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
|
|
984
|
+
]);
|
|
985
|
+
const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
|
|
986
|
+
assert.equal(ok, false, "strict rejection without skipInvalid");
|
|
987
|
+
assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
|
|
991
|
+
const { store, service } = dreamSetup();
|
|
992
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
|
|
993
|
+
const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
|
|
994
|
+
const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
|
|
995
|
+
const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
|
|
996
|
+
const warnings = [];
|
|
997
|
+
const ctx = {
|
|
998
|
+
logger: { warn: (m) => warnings.push(String(m)) },
|
|
999
|
+
llm: {
|
|
1000
|
+
async *stream(options) {
|
|
1001
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1002
|
+
if (userText.startsWith("id=")) {
|
|
1003
|
+
// 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
|
|
1004
|
+
// 高于 50% 显式覆盖率下限)
|
|
1005
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
1006
|
+
{ action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
|
|
1007
|
+
{ action: "archive", ids: [b.id], reason: "stale" },
|
|
1008
|
+
{ action: "archive", ids: [c.id], reason: "stale" }
|
|
1009
|
+
]) };
|
|
1010
|
+
} else {
|
|
1011
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
|
|
1012
|
+
}
|
|
1013
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
1018
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
|
|
1019
|
+
assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
|
|
1020
|
+
assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
|
|
1021
|
+
assert.equal(store.getById(b.id).archived, true, "valid archive applied");
|
|
1022
|
+
assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
|
|
1023
|
+
assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
|
|
1024
|
+
store.close();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
|
|
1028
|
+
const { store, service } = dreamSetup();
|
|
1029
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
|
|
1030
|
+
const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
|
|
1031
|
+
const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
|
|
1032
|
+
const ctx = {
|
|
1033
|
+
logger: { warn: () => {} },
|
|
1034
|
+
llm: {
|
|
1035
|
+
async *stream(options) {
|
|
1036
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1037
|
+
if (userText.startsWith("id=")) {
|
|
1038
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
1039
|
+
{ action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
|
|
1040
|
+
{ action: "archive", ids: [b.id], reason: "stale" }
|
|
1041
|
+
]) };
|
|
1042
|
+
} else {
|
|
1043
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览。" };
|
|
1044
|
+
}
|
|
1045
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
1050
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
|
|
1051
|
+
assert.equal(result.ok, false, "strict mode rejects the batch");
|
|
1052
|
+
assert.equal(result.error, "invalid decisions: 1 errors");
|
|
1053
|
+
assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
|
|
1054
|
+
store.close();
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
|
|
1058
|
+
const { store, service } = dreamSetup();
|
|
1059
|
+
let runs = 0;
|
|
1060
|
+
const dream = createDreamScheduler({
|
|
1061
|
+
onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
|
|
1062
|
+
thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
|
|
1063
|
+
logger: { warn: () => {} }
|
|
1064
|
+
});
|
|
1065
|
+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
1066
|
+
assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
|
|
1067
|
+
await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
|
|
1068
|
+
assert.equal(runs, 1, "ran once");
|
|
1069
|
+
assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
|
|
1070
|
+
await new Promise((r) => setTimeout(r, 90));
|
|
1071
|
+
assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
|
|
1072
|
+
store.close();
|
|
1073
|
+
});
|