@modusensus/dsh-mneme 0.8.14 → 0.8.15
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/LICENSE +21 -21
- package/README.md +655 -654
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -673
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -843
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -503
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -229
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -215
- package/lib/service.js +2663 -2605
- package/lib/settings.js +517 -511
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +118 -118
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -36
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -673
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -843
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -503
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -229
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -215
- package/src/service.js +2663 -2605
- package/src/settings.js +517 -511
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1690
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -60
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +196 -196
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -751
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/write-admission.test.js +668 -668
package/src/dream/sleep.js
CHANGED
|
@@ -1,819 +1,819 @@
|
|
|
1
|
-
import { STR, langOf } from "../lang.js";
|
|
2
|
-
// System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
|
|
3
|
-
// the memory store. Four independent, fail-safe phases:
|
|
4
|
-
// 1. conflict resolution — high-similarity same-type pairs are either parked
|
|
5
|
-
// for review (freeze mode) or adjudicated by the LLM (winner kept / loser
|
|
6
|
-
// archived), reusing the dream conflict machinery. Strictness-graded.
|
|
7
|
-
// v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
|
|
8
|
-
// ownership review with a dedicated reason — never auto-adjudicated.
|
|
9
|
-
// 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
|
|
10
|
-
// to a one-line summary with the full body moved to _full_content; past
|
|
11
|
-
// sleepCompressDays they are archived outright.
|
|
12
|
-
// 3. pattern discovery — the LLM scans the most recent memories and mints
|
|
13
|
-
// type=pattern entries carrying evidence id references.
|
|
14
|
-
// 4. relation completion — orphan entities (zero relations) get implied
|
|
15
|
-
// relations completed from memory co-occurrence.
|
|
16
|
-
// Each phase is wrapped so one failure never aborts the others, and a missing
|
|
17
|
-
// LLM route / semantic embedder only skips the phases that need it. A run is
|
|
18
|
-
// abortable via an AbortController signal (user activity) — phases check the
|
|
19
|
-
// signal between batches so a running cycle yields promptly.
|
|
20
|
-
import { randomUUID, createHash } from "node:crypto";
|
|
21
|
-
import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
|
|
22
|
-
import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
|
|
23
|
-
import { scopeKeyOf } from "../scope.js";
|
|
24
|
-
import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
|
|
25
|
-
import { computeHeat } from "../heat.js";
|
|
26
|
-
|
|
27
|
-
// Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
|
|
28
|
-
// ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
|
|
29
|
-
// 只换 prompt 挡不住模型自发输出新动作,必须由校验器按档位收口。
|
|
30
|
-
const LEGACY_ACTIONS = [...ACTIONS].filter((a) => a !== "supersede" && a !== "differentiate");
|
|
31
|
-
|
|
32
|
-
const SUMMARY_MAX = 120;
|
|
33
|
-
// Conflict similarity threshold per strictness level (v0.4.0):
|
|
34
|
-
// gentle only high-confidence pairs (0.92) — first-time users
|
|
35
|
-
// normal standard dream-level (0.85) — default
|
|
36
|
-
// aggressive low-confidence pairs too (0.75) — bloated stores
|
|
37
|
-
const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
|
|
38
|
-
|
|
39
|
-
function parseJsonArray(text) {
|
|
40
|
-
if (typeof text !== "string") return undefined;
|
|
41
|
-
const start = text.indexOf("[");
|
|
42
|
-
const end = text.lastIndexOf("]");
|
|
43
|
-
if (start === -1 || end <= start) return undefined;
|
|
44
|
-
try {
|
|
45
|
-
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
46
|
-
return Array.isArray(parsed) ? parsed : undefined;
|
|
47
|
-
} catch {
|
|
48
|
-
return undefined;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Same stream consumption contract as dream.js.
|
|
54
|
-
*
|
|
55
|
-
* `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
|
|
56
|
-
* dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
|
|
57
|
-
* token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
|
|
58
|
-
*/
|
|
59
|
-
async function streamText(ctx, options, onUsage, onStreamError) {
|
|
60
|
-
if (!ctx?.llm?.stream) return undefined;
|
|
61
|
-
let text = "";
|
|
62
|
-
for await (const chunk of ctx.llm.stream(options)) {
|
|
63
|
-
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
64
|
-
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
|
|
65
|
-
// outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
|
|
66
|
-
// 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
|
|
67
|
-
if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
|
|
68
|
-
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
69
|
-
// Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
|
|
70
|
-
// cause instead of discarding it.
|
|
71
|
-
if (typeof onStreamError === "function") {
|
|
72
|
-
try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
|
|
73
|
-
}
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return text;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
|
|
82
|
-
* 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
|
|
83
|
-
* checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
|
|
84
|
-
*
|
|
85
|
-
* 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
|
|
86
|
-
* fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
|
|
87
|
-
* reportUsage 上报。
|
|
88
|
-
*
|
|
89
|
-
* 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
|
|
90
|
-
* 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
|
|
91
|
-
* 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
|
|
92
|
-
* 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
|
|
93
|
-
* 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
|
|
94
|
-
*/
|
|
95
|
-
async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
|
|
96
|
-
if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
|
|
97
|
-
return body(() => {});
|
|
98
|
-
}
|
|
99
|
-
const startedAt = Date.now();
|
|
100
|
-
const timestamp = new Date(startedAt).toISOString();
|
|
101
|
-
let inputTokens = 0;
|
|
102
|
-
let outputTokens = 0;
|
|
103
|
-
let status = "success";
|
|
104
|
-
let errorMessage = null;
|
|
105
|
-
try {
|
|
106
|
-
const result = await body((usage) => {
|
|
107
|
-
if (!usage) return;
|
|
108
|
-
const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
|
109
|
-
const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
|
110
|
-
if (Number.isFinite(i)) inputTokens = i;
|
|
111
|
-
if (Number.isFinite(o)) outputTokens = o;
|
|
112
|
-
});
|
|
113
|
-
if (result === undefined) {
|
|
114
|
-
status = "error";
|
|
115
|
-
const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
|
|
116
|
-
errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
|
|
117
|
-
} else if (typeof spec.auditError === "function") {
|
|
118
|
-
// 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
|
|
119
|
-
// failed 返回值打架。
|
|
120
|
-
const message = spec.auditError(result);
|
|
121
|
-
if (message) {
|
|
122
|
-
status = "error";
|
|
123
|
-
errorMessage = message;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return result;
|
|
127
|
-
} catch (error) {
|
|
128
|
-
status = "error";
|
|
129
|
-
errorMessage = String(error?.message ?? error);
|
|
130
|
-
throw error;
|
|
131
|
-
} finally {
|
|
132
|
-
try {
|
|
133
|
-
service.saveLlmAudit({
|
|
134
|
-
timestamp,
|
|
135
|
-
trigger_source: spec.triggerSource,
|
|
136
|
-
operation_type: spec.operationType,
|
|
137
|
-
model_id: spec.modelId,
|
|
138
|
-
input_tokens: inputTokens,
|
|
139
|
-
output_tokens: outputTokens,
|
|
140
|
-
total_tokens: inputTokens + outputTokens,
|
|
141
|
-
cost_usd: 0,
|
|
142
|
-
duration_ms: Date.now() - startedAt,
|
|
143
|
-
status,
|
|
144
|
-
error_message: errorMessage,
|
|
145
|
-
related_memory_ids: spec.relatedMemoryIds ?? []
|
|
146
|
-
});
|
|
147
|
-
} catch (auditError) {
|
|
148
|
-
logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
|
|
154
|
-
* route as a shared explicit fallback, then the agent default model. Sleep
|
|
155
|
-
* can pin a cheaper model for its bulk passes without disturbing the dream
|
|
156
|
-
* route. Explicit config first — otherwise the config routes are dead code
|
|
157
|
-
* whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
|
|
158
|
-
function resolveSleepRoute(ctx, config, logger) {
|
|
159
|
-
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
160
|
-
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
161
|
-
try {
|
|
162
|
-
const sel = ctx?.agentDefaultModel?.currentSelection?.();
|
|
163
|
-
if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
|
|
164
|
-
} catch { /* fall through to warn */ }
|
|
165
|
-
logger?.warn?.("dsh-mneme sleep: no llm route available");
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function makeSummary(m) {
|
|
170
|
-
const text = (m.content ?? "").trim();
|
|
171
|
-
if (!text) return (m.title ?? "").trim();
|
|
172
|
-
return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ---------------------------------------------------------------- phases
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Phase 1 — conflict resolution. Needs a semantic embedder + vector index.
|
|
179
|
-
* In freeze mode (conflictFreezeEnabled) conflicting pairs are parked in
|
|
180
|
-
* conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
|
|
181
|
-
* each pair → winner kept / loser archived. Returns a per-run summary.
|
|
182
|
-
*/
|
|
183
|
-
async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
|
|
184
|
-
const language = langOf(config);
|
|
185
|
-
const embedder = semantic?.embedder;
|
|
186
|
-
const vectorIndex = semantic?.vectorIndex;
|
|
187
|
-
if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
|
|
188
|
-
return { status: "skipped", reason: "no semantic embedder" };
|
|
189
|
-
}
|
|
190
|
-
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
191
|
-
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
192
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document");
|
|
193
|
-
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
194
|
-
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
195
|
-
|
|
196
|
-
// Backfill + collect vectors for every eligible memory (best effort).
|
|
197
|
-
const vectors = new Array(memories.length);
|
|
198
|
-
const missing = [];
|
|
199
|
-
for (let i = 0; i < memories.length; i++) {
|
|
200
|
-
const cached = vectorIndex.getEmbedding?.(memories[i].id);
|
|
201
|
-
if (cached) vectors[i] = cached;
|
|
202
|
-
else missing.push(i);
|
|
203
|
-
}
|
|
204
|
-
if (missing.length) {
|
|
205
|
-
try {
|
|
206
|
-
const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
|
|
207
|
-
const rows = await embedder.embed(texts);
|
|
208
|
-
missing.forEach((mi, j) => {
|
|
209
|
-
if (rows[j]?.length) {
|
|
210
|
-
vectors[mi] = rows[j];
|
|
211
|
-
vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
} catch (error) {
|
|
215
|
-
logger?.warn?.(`dsh-mneme sleep: conflict vector backfill failed: ${String(error)}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
const usable = [];
|
|
219
|
-
for (let i = 0; i < memories.length; i++) {
|
|
220
|
-
if (vectors[i]?.length) usable.push(i);
|
|
221
|
-
}
|
|
222
|
-
if (usable.length < 2) return { status: "skipped", reason: "no usable vectors" };
|
|
223
|
-
const usableMemories = usable.map((i) => memories[i]);
|
|
224
|
-
const usableVectors = usable.map((i) => vectors[i]);
|
|
225
|
-
|
|
226
|
-
// Issue #126:动作集档位既要决定 prompt,也要决定**校验器允许的动作集合**(见下方
|
|
227
|
-
// allowedActions),并且在候选构造阶段就要知道——full 档需要补跨类型对。因此在这里
|
|
228
|
-
// 解析,而不是等到拼 prompt 时。
|
|
229
|
-
const actionSet = config.sleepActionSet ?? "conflict";
|
|
230
|
-
const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
|
|
231
|
-
if (actionSet === "full") {
|
|
232
|
-
// Issue #126 review(Copilot):findPotentialConflicts 只看同类型,而 full 档的
|
|
233
|
-
// prompt 明确宣传 differentiate 用于互补型——互补本来就常跨类型(同一事实的项目侧
|
|
234
|
-
// 与偏好侧)。不补这一遍,那条路径永远选不到。merge 的类型守卫不受影响:它拦在
|
|
235
|
-
// validateDecisions 里(allowCrossTypeMerge),不在候选生成。
|
|
236
|
-
for (let i = 0; i < usableMemories.length; i++) {
|
|
237
|
-
for (let j = i + 1; j < usableMemories.length; j++) {
|
|
238
|
-
if (usableMemories[i].type === usableMemories[j].type) continue;
|
|
239
|
-
const sim = cosineSimilarity(usableVectors[i], usableVectors[j]);
|
|
240
|
-
if (sim > threshold) pairs.push({ a: usableMemories[i], b: usableMemories[j], similarity: sim });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
|
|
245
|
-
|
|
246
|
-
// Dedupe: each memory participates in at most one pair, highest similarity
|
|
247
|
-
// first — overlapping pairs would violate validateDecisions' "one claim".
|
|
248
|
-
pairs.sort((a, b) => b.similarity - a.similarity);
|
|
249
|
-
const used = new Set();
|
|
250
|
-
const selected = [];
|
|
251
|
-
for (const p of pairs) {
|
|
252
|
-
if (used.has(p.a.id) || used.has(p.b.id)) continue;
|
|
253
|
-
used.add(p.a.id);
|
|
254
|
-
used.add(p.b.id);
|
|
255
|
-
selected.push(p);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
|
|
259
|
-
// (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
|
|
260
|
-
// 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
|
|
261
|
-
// 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
|
|
262
|
-
// (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
|
|
263
|
-
const isCrossScopePair = (p) =>
|
|
264
|
-
scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
|
|
265
|
-
|| scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
|
|
266
|
-
const crossPairs = selected.filter(isCrossScopePair);
|
|
267
|
-
const samePairs = selected.filter((p) => !isCrossScopePair(p));
|
|
268
|
-
const parkPair = (p, reason) => {
|
|
269
|
-
try {
|
|
270
|
-
// 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
|
|
271
|
-
return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
};
|
|
277
|
-
const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
|
|
278
|
-
const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
|
|
279
|
-
|
|
280
|
-
// Freeze mode: park pairs for manual review, no LLM required. Cross-scope
|
|
281
|
-
// pairs carry the dedicated scope-candidate reason so the queue reads them
|
|
282
|
-
// as ownership decisions rather than plain duplicates.
|
|
283
|
-
if (config.conflictFreezeEnabled === true) {
|
|
284
|
-
let frozen = 0;
|
|
285
|
-
for (const p of selected) {
|
|
286
|
-
if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
|
|
287
|
-
}
|
|
288
|
-
return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
|
|
292
|
-
let scopeParked = 0;
|
|
293
|
-
for (const p of crossPairs) {
|
|
294
|
-
if (parkPair(p, scopeReason(p))) scopeParked++;
|
|
295
|
-
}
|
|
296
|
-
if (samePairs.length === 0) {
|
|
297
|
-
return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// LLM adjudication(仅同 scope 对)。
|
|
301
|
-
const route = resolveSleepRoute(ctx, config, logger);
|
|
302
|
-
if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
|
|
303
|
-
const snapshot = new Map();
|
|
304
|
-
for (const p of samePairs) {
|
|
305
|
-
snapshot.set(p.a.id, p.a);
|
|
306
|
-
snapshot.set(p.b.id, p.b);
|
|
307
|
-
}
|
|
308
|
-
const listText = samePairs.map((p) =>
|
|
309
|
-
STR.candidateConflicts[language](p)
|
|
310
|
-
).join("\n\n");
|
|
311
|
-
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
312
|
-
let conflictStreamFailure = "";
|
|
313
|
-
// actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
|
|
314
|
-
// 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
|
|
315
|
-
const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
|
|
316
|
-
const runConflict = (withEffort) => {
|
|
317
|
-
conflictStreamFailure = "";
|
|
318
|
-
return auditedSleepLlm(ctx, service, config, logger, {
|
|
319
|
-
triggerSource: "sleep",
|
|
320
|
-
operationType: "sleep_conflict",
|
|
321
|
-
modelId: `${route.provider}:${route.model}`,
|
|
322
|
-
// 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
|
|
323
|
-
relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
|
|
324
|
-
streamError: () => conflictStreamFailure,
|
|
325
|
-
// 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
|
|
326
|
-
// 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
|
|
327
|
-
// 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
|
|
328
|
-
// 多解析一次,换来两条路径彻底解耦。
|
|
329
|
-
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
|
|
330
|
-
}, (reportUsage) => streamText(ctx, {
|
|
331
|
-
provider: route.provider,
|
|
332
|
-
model: route.model,
|
|
333
|
-
purpose: "sleep-conflict",
|
|
334
|
-
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
335
|
-
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
336
|
-
messages: [
|
|
337
|
-
{ role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
338
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
339
|
-
]
|
|
340
|
-
}, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
|
|
341
|
-
};
|
|
342
|
-
const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
|
|
343
|
-
if (text === undefined) {
|
|
344
|
-
if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
|
|
345
|
-
return { status: "failed", error: "llm failed" };
|
|
346
|
-
}
|
|
347
|
-
const decisions = parseJsonArray(text);
|
|
348
|
-
if (!decisions) return { status: "failed", error: "invalid decisions json" };
|
|
349
|
-
// validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
|
|
350
|
-
// 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
|
|
351
|
-
// 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
|
|
352
|
-
const covered = new Set();
|
|
353
|
-
for (const d of decisions) {
|
|
354
|
-
// Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
|
|
355
|
-
// 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
|
|
356
|
-
// supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
|
|
357
|
-
if (d?.action === "conflict" || d?.action === "supersede") {
|
|
358
|
-
if (typeof d?.winner === "string") covered.add(d.winner);
|
|
359
|
-
if (typeof d?.loser === "string") covered.add(d.loser);
|
|
360
|
-
} else if (Array.isArray(d?.ids)) {
|
|
361
|
-
for (const id of d.ids) covered.add(id);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
for (const id of snapshot.keys()) {
|
|
365
|
-
if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
|
|
366
|
-
}
|
|
367
|
-
// Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
|
|
368
|
-
// 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
|
|
369
|
-
// 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
|
|
370
|
-
// 模块对"什么算合法"的判据一致。
|
|
371
|
-
const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
|
|
372
|
-
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
373
|
-
minAgeHours: config.reflectionUpdateMinAgeHours,
|
|
374
|
-
skipInvalid: config.dreamSkipInvalid !== false,
|
|
375
|
-
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
|
|
376
|
-
// issue #339 / E8:guarded 类型 merge
|
|
377
|
-
// 与 dream 主链路同一判据。
|
|
378
|
-
mergeGuard: config.dreamMergeGuard === true,
|
|
379
|
-
// Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
|
|
380
|
-
// 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
|
|
381
|
-
allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
|
|
382
|
-
});
|
|
383
|
-
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
|
|
384
|
-
const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
|
|
385
|
-
// Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
|
|
386
|
-
// 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
|
|
387
|
-
// supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
|
|
388
|
-
if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
|
|
389
|
-
try {
|
|
390
|
-
await maintainIndexAfterDream(decisions, service, semantic);
|
|
391
|
-
} catch (error) {
|
|
392
|
-
logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
// Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
|
|
396
|
-
// 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
|
|
397
|
-
const degraded = skipped.length > 0;
|
|
398
|
-
return {
|
|
399
|
-
status: applied > 0 ? (degraded ? "degraded" : "ok") : failures.length ? "failed" : "noop",
|
|
400
|
-
pairs: selected.length,
|
|
401
|
-
applied,
|
|
402
|
-
failures,
|
|
403
|
-
conflicts,
|
|
404
|
-
// Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
|
|
405
|
-
// 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
|
|
406
|
-
skipped
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Phase 2 — archival demotion. No LLM: tiering is time-based, summaries are
|
|
412
|
-
* truncations, and the full body is preserved in _full_content so nothing is
|
|
413
|
-
* lost. Deterministic and cheap, so it runs even with no LLM route.
|
|
414
|
-
*/
|
|
415
|
-
function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
416
|
-
const archiveDays = config.sleepArchiveDays ?? 30;
|
|
417
|
-
const compressDays = config.sleepCompressDays ?? 90;
|
|
418
|
-
const archiveCut = Date.now() - archiveDays * 86400000;
|
|
419
|
-
const compressCut = Date.now() - compressDays * 86400000;
|
|
420
|
-
const demoted = [];
|
|
421
|
-
const archived = [];
|
|
422
|
-
for (const m of service.all()) {
|
|
423
|
-
if (signal?.aborted) break;
|
|
424
|
-
if (m.archived || m.forgotten) continue;
|
|
425
|
-
const ref = m.last_accessed_at ?? m.created_at;
|
|
426
|
-
if (!ref) continue;
|
|
427
|
-
const t = new Date(ref).getTime();
|
|
428
|
-
if (Number.isNaN(t)) continue;
|
|
429
|
-
// v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
|
|
430
|
-
// 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
|
|
431
|
-
// sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
|
|
432
|
-
// 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
|
|
433
|
-
// ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
|
|
434
|
-
const heatOn = config.heatEnabled !== false;
|
|
435
|
-
if (heatOn) {
|
|
436
|
-
const heat = computeHeat(m, Date.now(), config);
|
|
437
|
-
const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
|
|
438
|
-
const important = (m.importance ?? 0) >= 5;
|
|
439
|
-
if (heatProtected || important) continue;
|
|
440
|
-
}
|
|
441
|
-
if (t < compressCut) {
|
|
442
|
-
service.setArchived(m.id, true);
|
|
443
|
-
archived.push(m.id);
|
|
444
|
-
} else if (t < archiveCut) {
|
|
445
|
-
// minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
|
|
446
|
-
// a recall touch landing after this snapshot must not demote the memory.
|
|
447
|
-
service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
|
|
448
|
-
demoted.push(m.id);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return {
|
|
452
|
-
status: demoted.length || archived.length ? "ok" : "noop",
|
|
453
|
-
demoted,
|
|
454
|
-
archived
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Phase 3 — pattern discovery. The LLM scans the most recent memories and
|
|
460
|
-
* mints type=pattern entries (create actions) with evidence references.
|
|
461
|
-
* The empty snapshot is intentional: create claims no existing id, so the
|
|
462
|
-
* "every id claimed" invariant is trivially satisfied for pure-create lists.
|
|
463
|
-
*/
|
|
464
|
-
async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
|
|
465
|
-
const language = langOf(config);
|
|
466
|
-
const route = resolveSleepRoute(ctx, config, logger);
|
|
467
|
-
if (!route) return { status: "skipped", reason: "no llm route" };
|
|
468
|
-
const limit = config.sleepPatternMinMemories ?? 100;
|
|
469
|
-
// #230:document 在查询层排除(excludeTypes)而不是截断后过滤——LIMIT 200
|
|
470
|
-
// 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
|
|
471
|
-
const memories = service
|
|
472
|
-
.list({ limit: 200, includeForgotten: false, excludeTypes: ["document"] })
|
|
473
|
-
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
474
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
475
|
-
.slice(0, limit);
|
|
476
|
-
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
477
|
-
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
478
|
-
const listText = memories
|
|
479
|
-
.map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
|
|
480
|
-
.join("\n");
|
|
481
|
-
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
482
|
-
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
483
|
-
let patternStreamFailure = "";
|
|
484
|
-
const runPattern = (withEffort) => {
|
|
485
|
-
patternStreamFailure = "";
|
|
486
|
-
return auditedSleepLlm(ctx, service, config, logger, {
|
|
487
|
-
triggerSource: "sleep",
|
|
488
|
-
operationType: "sleep_pattern",
|
|
489
|
-
modelId: `${route.provider}:${route.model}`,
|
|
490
|
-
// 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
|
|
491
|
-
relatedMemoryIds: memories.map((m) => m.id),
|
|
492
|
-
streamError: () => patternStreamFailure,
|
|
493
|
-
// 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
|
|
494
|
-
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
|
|
495
|
-
}, (reportUsage) => streamText(ctx, {
|
|
496
|
-
provider: route.provider,
|
|
497
|
-
model: route.model,
|
|
498
|
-
purpose: "sleep-pattern",
|
|
499
|
-
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
500
|
-
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
501
|
-
messages: [
|
|
502
|
-
{ role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
503
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
504
|
-
]
|
|
505
|
-
}, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
|
|
506
|
-
};
|
|
507
|
-
const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
|
|
508
|
-
if (text === undefined) {
|
|
509
|
-
if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
|
|
510
|
-
return { status: "failed", error: "llm failed" };
|
|
511
|
-
}
|
|
512
|
-
const decisions = parseJsonArray(text);
|
|
513
|
-
if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
|
|
514
|
-
// Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
|
|
515
|
-
// ev:tag pointing nowhere. Intersect evidence with the scanned set so only
|
|
516
|
-
// real memory references survive.
|
|
517
|
-
const scannedIds = new Set(memories.map((m) => m.id));
|
|
518
|
-
for (const d of decisions) {
|
|
519
|
-
if (d?.action === "create" && Array.isArray(d.evidence)) {
|
|
520
|
-
d.evidence = d.evidence.filter((id) => typeof id === "string" && scannedIds.has(id));
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
const snapshot = new Map();
|
|
524
|
-
const { ok, errors } = validateDecisions(decisions, snapshot, {
|
|
525
|
-
maxCreatePerRun: maxPatterns
|
|
526
|
-
});
|
|
527
|
-
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
|
|
528
|
-
const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
|
|
529
|
-
return {
|
|
530
|
-
status: applied > 0 ? "ok" : "noop",
|
|
531
|
-
scanned: memories.length,
|
|
532
|
-
applied,
|
|
533
|
-
failures,
|
|
534
|
-
conflicts
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Phase 4 — entity relation completion. Detects orphan entities (zero
|
|
540
|
-
* relations) and completes implied relations from memory co-occurrence:
|
|
541
|
-
* entities named in the same memory → related_to; container kinds
|
|
542
|
-
* (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
|
|
543
|
-
* no LLM — cheap, so it runs even without a route. saveRelation is
|
|
544
|
-
* bookkeeping (no write hook), so it never re-triggers the scheduler.
|
|
545
|
-
*/
|
|
546
|
-
function inferRelationType(a, b) {
|
|
547
|
-
if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
|
|
548
|
-
if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
|
|
549
|
-
if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
|
|
550
|
-
return "related_to";
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function phaseRelations(service, config, logger, runId, signal = null) {
|
|
554
|
-
const entities = service.listEntities({ limit: 1000 }) ?? [];
|
|
555
|
-
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
556
|
-
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
557
|
-
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
558
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
559
|
-
const seen = new Set();
|
|
560
|
-
const related = [];
|
|
561
|
-
const MAX_RELATIONS_PER_ORPHAN = 3;
|
|
562
|
-
for (const o of orphans) {
|
|
563
|
-
if (signal?.aborted) break;
|
|
564
|
-
let made = 0;
|
|
565
|
-
for (const m of memories) {
|
|
566
|
-
if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
|
|
567
|
-
const text = `${m.title ?? ""} ${m.content ?? ""}`;
|
|
568
|
-
if (!text.includes(o.name)) continue;
|
|
569
|
-
for (const other of entities) {
|
|
570
|
-
if (other.id === o.id || other.name === o.name) continue;
|
|
571
|
-
const key = [o.id, other.id].sort().join("|");
|
|
572
|
-
if (seen.has(key)) continue;
|
|
573
|
-
if (!text.includes(other.name)) continue;
|
|
574
|
-
const relationType = inferRelationType(o, other);
|
|
575
|
-
try {
|
|
576
|
-
service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
|
|
577
|
-
seen.add(key);
|
|
578
|
-
related.push({ from: o.id, to: other.id, type: relationType });
|
|
579
|
-
made++;
|
|
580
|
-
} catch (error) {
|
|
581
|
-
logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
status: related.length > 0 ? "ok" : "noop",
|
|
588
|
-
orphanCount: orphans.length,
|
|
589
|
-
related
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// ---------------------------------------------------------------- run
|
|
594
|
-
|
|
595
|
-
function deriveStatus(phases) {
|
|
596
|
-
const list = Object.values(phases);
|
|
597
|
-
if (list.length === 0) return "noop";
|
|
598
|
-
const anyError = list.some((p) => p.status === "failed" || p.status === "error");
|
|
599
|
-
// Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
|
|
600
|
-
// 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
|
|
601
|
-
const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
|
|
602
|
-
const anyDegraded = list.some((p) => p.status === "degraded");
|
|
603
|
-
if (anyDegraded) return "degraded";
|
|
604
|
-
if (anyWork && anyError) return "degraded";
|
|
605
|
-
if (anyError) return "failed";
|
|
606
|
-
if (anyWork) return "ok";
|
|
607
|
-
return "noop";
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Run one full sleep cycle. Best-effort across all phases; writes a
|
|
612
|
-
* run_type='sleep' audit row (same dream_runs table) so sleep activity is
|
|
613
|
-
* observable alongside consolidation runs.
|
|
614
|
-
*/
|
|
615
|
-
export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
|
|
616
|
-
const runId = randomUUID();
|
|
617
|
-
// Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
|
|
618
|
-
// (LLM 各 phase)不应计入重启后的冷却窗口。
|
|
619
|
-
const sleepStartedAt = Date.now();
|
|
620
|
-
const phases = {};
|
|
621
|
-
const attempt = async (name, fn) => {
|
|
622
|
-
if (signal?.aborted) return; // user resumed activity — stop before next phase
|
|
623
|
-
try {
|
|
624
|
-
phases[name] = await fn();
|
|
625
|
-
} catch (error) {
|
|
626
|
-
phases[name] = { status: "failed", error: error?.message ?? String(error) };
|
|
627
|
-
logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
|
|
628
|
-
}
|
|
629
|
-
};
|
|
630
|
-
await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
|
|
631
|
-
await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
|
|
632
|
-
await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
|
|
633
|
-
await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
|
|
634
|
-
|
|
635
|
-
const status = deriveStatus(phases);
|
|
636
|
-
const route = resolveSleepRoute(ctx, config, logger);
|
|
637
|
-
const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
|
|
638
|
-
// Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
|
|
639
|
-
// 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
|
|
640
|
-
// 不落空数组——审计行只记真实发生过的跳过。
|
|
641
|
-
const skippedDetail = Object.entries(phases).flatMap(([phase, p]) =>
|
|
642
|
-
Array.isArray(p?.skipped) ? p.skipped.map((s) => ({ phase, ...s })) : []
|
|
643
|
-
);
|
|
644
|
-
const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
|
|
645
|
-
const receipt = buildReceipt({
|
|
646
|
-
runId,
|
|
647
|
-
status,
|
|
648
|
-
snapshotHash,
|
|
649
|
-
inputCount: 0,
|
|
650
|
-
applied: totalApplied,
|
|
651
|
-
summaryStored: false
|
|
652
|
-
});
|
|
653
|
-
try {
|
|
654
|
-
service.saveDreamRun({
|
|
655
|
-
id: runId,
|
|
656
|
-
// Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
|
|
657
|
-
// 不该计入重启后的 sleep 冷却种子。
|
|
658
|
-
created_at: new Date(sleepStartedAt).toISOString(),
|
|
659
|
-
status,
|
|
660
|
-
provider: route?.provider,
|
|
661
|
-
model: route?.model,
|
|
662
|
-
snapshot_hash: snapshotHash,
|
|
663
|
-
input_count: 0,
|
|
664
|
-
input: null,
|
|
665
|
-
decisions: phases,
|
|
666
|
-
outcome: phases,
|
|
667
|
-
applied: totalApplied,
|
|
668
|
-
summary_stored: false,
|
|
669
|
-
receipt,
|
|
670
|
-
policy_epoch: config.policyEpoch ?? 0,
|
|
671
|
-
run_type: "sleep",
|
|
672
|
-
skipped: skippedDetail.length ? skippedDetail : undefined
|
|
673
|
-
});
|
|
674
|
-
} catch (error) {
|
|
675
|
-
logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
|
|
676
|
-
}
|
|
677
|
-
return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt };
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// ---------------------------------------------------------------- scheduler
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Idle-triggered scheduler. DSH plugins have no resident cron, so a sleep run
|
|
684
|
-
* fires when: sleep is enabled, the store has been quiet for sleepIdleMinutes,
|
|
685
|
-
* and the previous run is older than sleepMinIntervalHours. noteWrite() is
|
|
686
|
-
* called on every store write and (re)arms an idle timer that re-checks at the
|
|
687
|
-
* exact moment the idle window elapses — no polling, no cron.
|
|
688
|
-
*
|
|
689
|
-
* A `now` clock can be injected for tests; it defaults to Date.now.
|
|
690
|
-
*/
|
|
691
|
-
export function createSleepScheduler({
|
|
692
|
-
service,
|
|
693
|
-
config,
|
|
694
|
-
logger,
|
|
695
|
-
onRun = null,
|
|
696
|
-
now = () => Date.now(),
|
|
697
|
-
setTimeoutFn = setTimeout,
|
|
698
|
-
clearTimeoutFn = clearTimeout,
|
|
699
|
-
// Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
|
|
700
|
-
// 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
|
|
701
|
-
// 测试注入 0(默认)即保持旧行为。
|
|
702
|
-
lastRunAtSeed = 0
|
|
703
|
-
}) {
|
|
704
|
-
let lastWriteAt = now();
|
|
705
|
-
let lastRunAt = lastRunAtSeed;
|
|
706
|
-
let running = false;
|
|
707
|
-
let disposed = false;
|
|
708
|
-
let idleTimer = null;
|
|
709
|
-
let sleepAbort = null;
|
|
710
|
-
|
|
711
|
-
function armIdleTimer() {
|
|
712
|
-
if (disposed || idleTimer) return;
|
|
713
|
-
if (config.sleepModeEnabled !== true) return;
|
|
714
|
-
const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
|
|
715
|
-
const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
|
|
716
|
-
idleTimer = setTimeoutFn(async () => {
|
|
717
|
-
idleTimer = null;
|
|
718
|
-
// Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
|
|
719
|
-
// 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
|
|
720
|
-
const ran = await maybeSchedule();
|
|
721
|
-
if (!ran) armNextWindow();
|
|
722
|
-
}, delay);
|
|
723
|
-
idleTimer.unref?.();
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Issue #187:撞 CD / run 失败后的补挂表——按「剩余 CD / 剩余静默」里更晚的
|
|
727
|
-
// 到点时刻重排一次,回调里重复同样的判断所以不会空转。跑成了一轮就不重排:
|
|
728
|
-
// 安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
|
|
729
|
-
function armNextWindow() {
|
|
730
|
-
if (disposed || idleTimer) return;
|
|
731
|
-
if (config.sleepModeEnabled !== true) return;
|
|
732
|
-
// lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
|
|
733
|
-
if (lastRunAt <= 0) return;
|
|
734
|
-
const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
|
|
735
|
-
const cdMs = (config.sleepMinIntervalHours ?? 8) * 3600000;
|
|
736
|
-
const delay = Math.max(
|
|
737
|
-
lastRunAt + cdMs - now(),
|
|
738
|
-
lastWriteAt + idleMs - now(),
|
|
739
|
-
0
|
|
740
|
-
) + 1000;
|
|
741
|
-
idleTimer = setTimeoutFn(async () => {
|
|
742
|
-
idleTimer = null;
|
|
743
|
-
const ran = await maybeSchedule();
|
|
744
|
-
if (!ran) armNextWindow();
|
|
745
|
-
}, delay);
|
|
746
|
-
idleTimer.unref?.();
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function shouldRun(at = now()) {
|
|
750
|
-
if (disposed || running) return false;
|
|
751
|
-
if (config.sleepModeEnabled !== true) return false;
|
|
752
|
-
if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
|
|
753
|
-
// lastRunAt === 0 means never ran — the min-interval check must not block
|
|
754
|
-
// the very first cycle (a real run stamps a nonzero timestamp).
|
|
755
|
-
if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
|
|
756
|
-
return true;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/** Called on writes: resets the idle clock and re-arms the fire timer. The
|
|
760
|
-
* pending timer is cleared first — a stale timer armed against the old idle
|
|
761
|
-
* window would otherwise fire early, fail shouldRun, and leave nothing armed
|
|
762
|
-
* for the next window (a missed trigger until the next write).
|
|
763
|
-
*
|
|
764
|
-
* While a sleep run is executing (running=true) the in-flight AbortController
|
|
765
|
-
* is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
|
|
766
|
-
* the normal write-hook path) would otherwise self-abort the cycle. External
|
|
767
|
-
* activity during the run still resets the idle clock here, so no new cycle
|
|
768
|
-
* fires until the store is quiet again. */
|
|
769
|
-
function noteWrite() {
|
|
770
|
-
lastWriteAt = now();
|
|
771
|
-
if (!running && sleepAbort) {
|
|
772
|
-
sleepAbort.abort(); // user resumed activity — interrupt an idle run
|
|
773
|
-
}
|
|
774
|
-
if (idleTimer) {
|
|
775
|
-
clearTimeoutFn(idleTimer);
|
|
776
|
-
idleTimer = null;
|
|
777
|
-
}
|
|
778
|
-
armIdleTimer();
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function maybeSchedule() {
|
|
782
|
-
if (!shouldRun()) return false;
|
|
783
|
-
running = true;
|
|
784
|
-
const abort = new AbortController();
|
|
785
|
-
sleepAbort = abort;
|
|
786
|
-
try {
|
|
787
|
-
lastRunAt = now();
|
|
788
|
-
const result = await service.enqueue(() =>
|
|
789
|
-
onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
|
|
790
|
-
);
|
|
791
|
-
return !!(result && result.ok);
|
|
792
|
-
} catch (error) {
|
|
793
|
-
logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
|
|
794
|
-
return false;
|
|
795
|
-
} finally {
|
|
796
|
-
sleepAbort = null;
|
|
797
|
-
running = false;
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
async function dispose() {
|
|
802
|
-
disposed = true;
|
|
803
|
-
if (idleTimer) {
|
|
804
|
-
clearTimeoutFn(idleTimer);
|
|
805
|
-
idleTimer = null;
|
|
806
|
-
}
|
|
807
|
-
if (sleepAbort) {
|
|
808
|
-
sleepAbort.abort();
|
|
809
|
-
sleepAbort = null;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
|
|
814
|
-
// 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
|
|
815
|
-
// 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
|
|
816
|
-
armIdleTimer();
|
|
817
|
-
|
|
818
|
-
return { noteWrite, maybeSchedule, shouldRun, dispose };
|
|
819
|
-
}
|
|
1
|
+
import { STR, langOf } from "../lang.js";
|
|
2
|
+
// System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
|
|
3
|
+
// the memory store. Four independent, fail-safe phases:
|
|
4
|
+
// 1. conflict resolution — high-similarity same-type pairs are either parked
|
|
5
|
+
// for review (freeze mode) or adjudicated by the LLM (winner kept / loser
|
|
6
|
+
// archived), reusing the dream conflict machinery. Strictness-graded.
|
|
7
|
+
// v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
|
|
8
|
+
// ownership review with a dedicated reason — never auto-adjudicated.
|
|
9
|
+
// 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
|
|
10
|
+
// to a one-line summary with the full body moved to _full_content; past
|
|
11
|
+
// sleepCompressDays they are archived outright.
|
|
12
|
+
// 3. pattern discovery — the LLM scans the most recent memories and mints
|
|
13
|
+
// type=pattern entries carrying evidence id references.
|
|
14
|
+
// 4. relation completion — orphan entities (zero relations) get implied
|
|
15
|
+
// relations completed from memory co-occurrence.
|
|
16
|
+
// Each phase is wrapped so one failure never aborts the others, and a missing
|
|
17
|
+
// LLM route / semantic embedder only skips the phases that need it. A run is
|
|
18
|
+
// abortable via an AbortController signal (user activity) — phases check the
|
|
19
|
+
// signal between batches so a running cycle yields promptly.
|
|
20
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
21
|
+
import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
|
|
22
|
+
import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
|
|
23
|
+
import { scopeKeyOf } from "../scope.js";
|
|
24
|
+
import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
|
|
25
|
+
import { computeHeat } from "../heat.js";
|
|
26
|
+
|
|
27
|
+
// Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
|
|
28
|
+
// ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
|
|
29
|
+
// 只换 prompt 挡不住模型自发输出新动作,必须由校验器按档位收口。
|
|
30
|
+
const LEGACY_ACTIONS = [...ACTIONS].filter((a) => a !== "supersede" && a !== "differentiate");
|
|
31
|
+
|
|
32
|
+
const SUMMARY_MAX = 120;
|
|
33
|
+
// Conflict similarity threshold per strictness level (v0.4.0):
|
|
34
|
+
// gentle only high-confidence pairs (0.92) — first-time users
|
|
35
|
+
// normal standard dream-level (0.85) — default
|
|
36
|
+
// aggressive low-confidence pairs too (0.75) — bloated stores
|
|
37
|
+
const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
|
|
38
|
+
|
|
39
|
+
function parseJsonArray(text) {
|
|
40
|
+
if (typeof text !== "string") return undefined;
|
|
41
|
+
const start = text.indexOf("[");
|
|
42
|
+
const end = text.lastIndexOf("]");
|
|
43
|
+
if (start === -1 || end <= start) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
46
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Same stream consumption contract as dream.js.
|
|
54
|
+
*
|
|
55
|
+
* `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
|
|
56
|
+
* dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
|
|
57
|
+
* token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
|
|
58
|
+
*/
|
|
59
|
+
async function streamText(ctx, options, onUsage, onStreamError) {
|
|
60
|
+
if (!ctx?.llm?.stream) return undefined;
|
|
61
|
+
let text = "";
|
|
62
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
63
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
64
|
+
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
|
|
65
|
+
// outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
|
|
66
|
+
// 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
|
|
67
|
+
if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
|
|
68
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
69
|
+
// Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
|
|
70
|
+
// cause instead of discarding it.
|
|
71
|
+
if (typeof onStreamError === "function") {
|
|
72
|
+
try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
|
|
82
|
+
* 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
|
|
83
|
+
* checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
|
|
84
|
+
*
|
|
85
|
+
* 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
|
|
86
|
+
* fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
|
|
87
|
+
* reportUsage 上报。
|
|
88
|
+
*
|
|
89
|
+
* 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
|
|
90
|
+
* 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
|
|
91
|
+
* 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
|
|
92
|
+
* 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
|
|
93
|
+
* 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
|
|
94
|
+
*/
|
|
95
|
+
async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
|
|
96
|
+
if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
|
|
97
|
+
return body(() => {});
|
|
98
|
+
}
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
const timestamp = new Date(startedAt).toISOString();
|
|
101
|
+
let inputTokens = 0;
|
|
102
|
+
let outputTokens = 0;
|
|
103
|
+
let status = "success";
|
|
104
|
+
let errorMessage = null;
|
|
105
|
+
try {
|
|
106
|
+
const result = await body((usage) => {
|
|
107
|
+
if (!usage) return;
|
|
108
|
+
const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
|
109
|
+
const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
|
110
|
+
if (Number.isFinite(i)) inputTokens = i;
|
|
111
|
+
if (Number.isFinite(o)) outputTokens = o;
|
|
112
|
+
});
|
|
113
|
+
if (result === undefined) {
|
|
114
|
+
status = "error";
|
|
115
|
+
const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
|
|
116
|
+
errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
|
|
117
|
+
} else if (typeof spec.auditError === "function") {
|
|
118
|
+
// 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
|
|
119
|
+
// failed 返回值打架。
|
|
120
|
+
const message = spec.auditError(result);
|
|
121
|
+
if (message) {
|
|
122
|
+
status = "error";
|
|
123
|
+
errorMessage = message;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
status = "error";
|
|
129
|
+
errorMessage = String(error?.message ?? error);
|
|
130
|
+
throw error;
|
|
131
|
+
} finally {
|
|
132
|
+
try {
|
|
133
|
+
service.saveLlmAudit({
|
|
134
|
+
timestamp,
|
|
135
|
+
trigger_source: spec.triggerSource,
|
|
136
|
+
operation_type: spec.operationType,
|
|
137
|
+
model_id: spec.modelId,
|
|
138
|
+
input_tokens: inputTokens,
|
|
139
|
+
output_tokens: outputTokens,
|
|
140
|
+
total_tokens: inputTokens + outputTokens,
|
|
141
|
+
cost_usd: 0,
|
|
142
|
+
duration_ms: Date.now() - startedAt,
|
|
143
|
+
status,
|
|
144
|
+
error_message: errorMessage,
|
|
145
|
+
related_memory_ids: spec.relatedMemoryIds ?? []
|
|
146
|
+
});
|
|
147
|
+
} catch (auditError) {
|
|
148
|
+
logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
|
|
154
|
+
* route as a shared explicit fallback, then the agent default model. Sleep
|
|
155
|
+
* can pin a cheaper model for its bulk passes without disturbing the dream
|
|
156
|
+
* route. Explicit config first — otherwise the config routes are dead code
|
|
157
|
+
* whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
|
|
158
|
+
function resolveSleepRoute(ctx, config, logger) {
|
|
159
|
+
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
160
|
+
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
161
|
+
try {
|
|
162
|
+
const sel = ctx?.agentDefaultModel?.currentSelection?.();
|
|
163
|
+
if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
|
|
164
|
+
} catch { /* fall through to warn */ }
|
|
165
|
+
logger?.warn?.("dsh-mneme sleep: no llm route available");
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function makeSummary(m) {
|
|
170
|
+
const text = (m.content ?? "").trim();
|
|
171
|
+
if (!text) return (m.title ?? "").trim();
|
|
172
|
+
return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------- phases
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Phase 1 — conflict resolution. Needs a semantic embedder + vector index.
|
|
179
|
+
* In freeze mode (conflictFreezeEnabled) conflicting pairs are parked in
|
|
180
|
+
* conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
|
|
181
|
+
* each pair → winner kept / loser archived. Returns a per-run summary.
|
|
182
|
+
*/
|
|
183
|
+
async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
|
|
184
|
+
const language = langOf(config);
|
|
185
|
+
const embedder = semantic?.embedder;
|
|
186
|
+
const vectorIndex = semantic?.vectorIndex;
|
|
187
|
+
if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
|
|
188
|
+
return { status: "skipped", reason: "no semantic embedder" };
|
|
189
|
+
}
|
|
190
|
+
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
191
|
+
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
192
|
+
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document");
|
|
193
|
+
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
194
|
+
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
195
|
+
|
|
196
|
+
// Backfill + collect vectors for every eligible memory (best effort).
|
|
197
|
+
const vectors = new Array(memories.length);
|
|
198
|
+
const missing = [];
|
|
199
|
+
for (let i = 0; i < memories.length; i++) {
|
|
200
|
+
const cached = vectorIndex.getEmbedding?.(memories[i].id);
|
|
201
|
+
if (cached) vectors[i] = cached;
|
|
202
|
+
else missing.push(i);
|
|
203
|
+
}
|
|
204
|
+
if (missing.length) {
|
|
205
|
+
try {
|
|
206
|
+
const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
|
|
207
|
+
const rows = await embedder.embed(texts);
|
|
208
|
+
missing.forEach((mi, j) => {
|
|
209
|
+
if (rows[j]?.length) {
|
|
210
|
+
vectors[mi] = rows[j];
|
|
211
|
+
vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger?.warn?.(`dsh-mneme sleep: conflict vector backfill failed: ${String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const usable = [];
|
|
219
|
+
for (let i = 0; i < memories.length; i++) {
|
|
220
|
+
if (vectors[i]?.length) usable.push(i);
|
|
221
|
+
}
|
|
222
|
+
if (usable.length < 2) return { status: "skipped", reason: "no usable vectors" };
|
|
223
|
+
const usableMemories = usable.map((i) => memories[i]);
|
|
224
|
+
const usableVectors = usable.map((i) => vectors[i]);
|
|
225
|
+
|
|
226
|
+
// Issue #126:动作集档位既要决定 prompt,也要决定**校验器允许的动作集合**(见下方
|
|
227
|
+
// allowedActions),并且在候选构造阶段就要知道——full 档需要补跨类型对。因此在这里
|
|
228
|
+
// 解析,而不是等到拼 prompt 时。
|
|
229
|
+
const actionSet = config.sleepActionSet ?? "conflict";
|
|
230
|
+
const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
|
|
231
|
+
if (actionSet === "full") {
|
|
232
|
+
// Issue #126 review(Copilot):findPotentialConflicts 只看同类型,而 full 档的
|
|
233
|
+
// prompt 明确宣传 differentiate 用于互补型——互补本来就常跨类型(同一事实的项目侧
|
|
234
|
+
// 与偏好侧)。不补这一遍,那条路径永远选不到。merge 的类型守卫不受影响:它拦在
|
|
235
|
+
// validateDecisions 里(allowCrossTypeMerge),不在候选生成。
|
|
236
|
+
for (let i = 0; i < usableMemories.length; i++) {
|
|
237
|
+
for (let j = i + 1; j < usableMemories.length; j++) {
|
|
238
|
+
if (usableMemories[i].type === usableMemories[j].type) continue;
|
|
239
|
+
const sim = cosineSimilarity(usableVectors[i], usableVectors[j]);
|
|
240
|
+
if (sim > threshold) pairs.push({ a: usableMemories[i], b: usableMemories[j], similarity: sim });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
|
|
245
|
+
|
|
246
|
+
// Dedupe: each memory participates in at most one pair, highest similarity
|
|
247
|
+
// first — overlapping pairs would violate validateDecisions' "one claim".
|
|
248
|
+
pairs.sort((a, b) => b.similarity - a.similarity);
|
|
249
|
+
const used = new Set();
|
|
250
|
+
const selected = [];
|
|
251
|
+
for (const p of pairs) {
|
|
252
|
+
if (used.has(p.a.id) || used.has(p.b.id)) continue;
|
|
253
|
+
used.add(p.a.id);
|
|
254
|
+
used.add(p.b.id);
|
|
255
|
+
selected.push(p);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
|
|
259
|
+
// (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
|
|
260
|
+
// 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
|
|
261
|
+
// 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
|
|
262
|
+
// (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
|
|
263
|
+
const isCrossScopePair = (p) =>
|
|
264
|
+
scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
|
|
265
|
+
|| scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
|
|
266
|
+
const crossPairs = selected.filter(isCrossScopePair);
|
|
267
|
+
const samePairs = selected.filter((p) => !isCrossScopePair(p));
|
|
268
|
+
const parkPair = (p, reason) => {
|
|
269
|
+
try {
|
|
270
|
+
// 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
|
|
271
|
+
return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
|
|
278
|
+
const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
|
|
279
|
+
|
|
280
|
+
// Freeze mode: park pairs for manual review, no LLM required. Cross-scope
|
|
281
|
+
// pairs carry the dedicated scope-candidate reason so the queue reads them
|
|
282
|
+
// as ownership decisions rather than plain duplicates.
|
|
283
|
+
if (config.conflictFreezeEnabled === true) {
|
|
284
|
+
let frozen = 0;
|
|
285
|
+
for (const p of selected) {
|
|
286
|
+
if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
|
|
287
|
+
}
|
|
288
|
+
return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
|
|
292
|
+
let scopeParked = 0;
|
|
293
|
+
for (const p of crossPairs) {
|
|
294
|
+
if (parkPair(p, scopeReason(p))) scopeParked++;
|
|
295
|
+
}
|
|
296
|
+
if (samePairs.length === 0) {
|
|
297
|
+
return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// LLM adjudication(仅同 scope 对)。
|
|
301
|
+
const route = resolveSleepRoute(ctx, config, logger);
|
|
302
|
+
if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
|
|
303
|
+
const snapshot = new Map();
|
|
304
|
+
for (const p of samePairs) {
|
|
305
|
+
snapshot.set(p.a.id, p.a);
|
|
306
|
+
snapshot.set(p.b.id, p.b);
|
|
307
|
+
}
|
|
308
|
+
const listText = samePairs.map((p) =>
|
|
309
|
+
STR.candidateConflicts[language](p)
|
|
310
|
+
).join("\n\n");
|
|
311
|
+
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
312
|
+
let conflictStreamFailure = "";
|
|
313
|
+
// actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
|
|
314
|
+
// 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
|
|
315
|
+
const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
|
|
316
|
+
const runConflict = (withEffort) => {
|
|
317
|
+
conflictStreamFailure = "";
|
|
318
|
+
return auditedSleepLlm(ctx, service, config, logger, {
|
|
319
|
+
triggerSource: "sleep",
|
|
320
|
+
operationType: "sleep_conflict",
|
|
321
|
+
modelId: `${route.provider}:${route.model}`,
|
|
322
|
+
// 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
|
|
323
|
+
relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
|
|
324
|
+
streamError: () => conflictStreamFailure,
|
|
325
|
+
// 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
|
|
326
|
+
// 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
|
|
327
|
+
// 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
|
|
328
|
+
// 多解析一次,换来两条路径彻底解耦。
|
|
329
|
+
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
|
|
330
|
+
}, (reportUsage) => streamText(ctx, {
|
|
331
|
+
provider: route.provider,
|
|
332
|
+
model: route.model,
|
|
333
|
+
purpose: "sleep-conflict",
|
|
334
|
+
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
335
|
+
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
336
|
+
messages: [
|
|
337
|
+
{ role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
338
|
+
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
339
|
+
]
|
|
340
|
+
}, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
|
|
341
|
+
};
|
|
342
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
|
|
343
|
+
if (text === undefined) {
|
|
344
|
+
if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
|
|
345
|
+
return { status: "failed", error: "llm failed" };
|
|
346
|
+
}
|
|
347
|
+
const decisions = parseJsonArray(text);
|
|
348
|
+
if (!decisions) return { status: "failed", error: "invalid decisions json" };
|
|
349
|
+
// validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
|
|
350
|
+
// 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
|
|
351
|
+
// 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
|
|
352
|
+
const covered = new Set();
|
|
353
|
+
for (const d of decisions) {
|
|
354
|
+
// Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
|
|
355
|
+
// 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
|
|
356
|
+
// supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
|
|
357
|
+
if (d?.action === "conflict" || d?.action === "supersede") {
|
|
358
|
+
if (typeof d?.winner === "string") covered.add(d.winner);
|
|
359
|
+
if (typeof d?.loser === "string") covered.add(d.loser);
|
|
360
|
+
} else if (Array.isArray(d?.ids)) {
|
|
361
|
+
for (const id of d.ids) covered.add(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const id of snapshot.keys()) {
|
|
365
|
+
if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
|
|
366
|
+
}
|
|
367
|
+
// Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
|
|
368
|
+
// 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
|
|
369
|
+
// 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
|
|
370
|
+
// 模块对"什么算合法"的判据一致。
|
|
371
|
+
const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
|
|
372
|
+
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
373
|
+
minAgeHours: config.reflectionUpdateMinAgeHours,
|
|
374
|
+
skipInvalid: config.dreamSkipInvalid !== false,
|
|
375
|
+
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
|
|
376
|
+
// issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
|
|
377
|
+
// 与 dream 主链路同一判据。
|
|
378
|
+
mergeGuard: config.dreamMergeGuard === true,
|
|
379
|
+
// Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
|
|
380
|
+
// 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
|
|
381
|
+
allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
|
|
382
|
+
});
|
|
383
|
+
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
|
|
384
|
+
const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
|
|
385
|
+
// Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
|
|
386
|
+
// 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
|
|
387
|
+
// supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
|
|
388
|
+
if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
|
|
389
|
+
try {
|
|
390
|
+
await maintainIndexAfterDream(decisions, service, semantic);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
|
|
396
|
+
// 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
|
|
397
|
+
const degraded = skipped.length > 0;
|
|
398
|
+
return {
|
|
399
|
+
status: applied > 0 ? (degraded ? "degraded" : "ok") : failures.length ? "failed" : "noop",
|
|
400
|
+
pairs: selected.length,
|
|
401
|
+
applied,
|
|
402
|
+
failures,
|
|
403
|
+
conflicts,
|
|
404
|
+
// Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
|
|
405
|
+
// 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
|
|
406
|
+
skipped
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Phase 2 — archival demotion. No LLM: tiering is time-based, summaries are
|
|
412
|
+
* truncations, and the full body is preserved in _full_content so nothing is
|
|
413
|
+
* lost. Deterministic and cheap, so it runs even with no LLM route.
|
|
414
|
+
*/
|
|
415
|
+
function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
416
|
+
const archiveDays = config.sleepArchiveDays ?? 30;
|
|
417
|
+
const compressDays = config.sleepCompressDays ?? 90;
|
|
418
|
+
const archiveCut = Date.now() - archiveDays * 86400000;
|
|
419
|
+
const compressCut = Date.now() - compressDays * 86400000;
|
|
420
|
+
const demoted = [];
|
|
421
|
+
const archived = [];
|
|
422
|
+
for (const m of service.all()) {
|
|
423
|
+
if (signal?.aborted) break;
|
|
424
|
+
if (m.archived || m.forgotten) continue;
|
|
425
|
+
const ref = m.last_accessed_at ?? m.created_at;
|
|
426
|
+
if (!ref) continue;
|
|
427
|
+
const t = new Date(ref).getTime();
|
|
428
|
+
if (Number.isNaN(t)) continue;
|
|
429
|
+
// v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
|
|
430
|
+
// 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
|
|
431
|
+
// sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
|
|
432
|
+
// 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
|
|
433
|
+
// ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
|
|
434
|
+
const heatOn = config.heatEnabled !== false;
|
|
435
|
+
if (heatOn) {
|
|
436
|
+
const heat = computeHeat(m, Date.now(), config);
|
|
437
|
+
const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
|
|
438
|
+
const important = (m.importance ?? 0) >= 5;
|
|
439
|
+
if (heatProtected || important) continue;
|
|
440
|
+
}
|
|
441
|
+
if (t < compressCut) {
|
|
442
|
+
service.setArchived(m.id, true);
|
|
443
|
+
archived.push(m.id);
|
|
444
|
+
} else if (t < archiveCut) {
|
|
445
|
+
// minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
|
|
446
|
+
// a recall touch landing after this snapshot must not demote the memory.
|
|
447
|
+
service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
|
|
448
|
+
demoted.push(m.id);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
status: demoted.length || archived.length ? "ok" : "noop",
|
|
453
|
+
demoted,
|
|
454
|
+
archived
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Phase 3 — pattern discovery. The LLM scans the most recent memories and
|
|
460
|
+
* mints type=pattern entries (create actions) with evidence references.
|
|
461
|
+
* The empty snapshot is intentional: create claims no existing id, so the
|
|
462
|
+
* "every id claimed" invariant is trivially satisfied for pure-create lists.
|
|
463
|
+
*/
|
|
464
|
+
async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
|
|
465
|
+
const language = langOf(config);
|
|
466
|
+
const route = resolveSleepRoute(ctx, config, logger);
|
|
467
|
+
if (!route) return { status: "skipped", reason: "no llm route" };
|
|
468
|
+
const limit = config.sleepPatternMinMemories ?? 100;
|
|
469
|
+
// #230:document 在查询层排除(excludeTypes)而不是截断后过滤——LIMIT 200
|
|
470
|
+
// 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
|
|
471
|
+
const memories = service
|
|
472
|
+
.list({ limit: 200, includeForgotten: false, excludeTypes: ["document"] })
|
|
473
|
+
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
474
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
475
|
+
.slice(0, limit);
|
|
476
|
+
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
477
|
+
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
478
|
+
const listText = memories
|
|
479
|
+
.map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
|
|
480
|
+
.join("\n");
|
|
481
|
+
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
482
|
+
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
483
|
+
let patternStreamFailure = "";
|
|
484
|
+
const runPattern = (withEffort) => {
|
|
485
|
+
patternStreamFailure = "";
|
|
486
|
+
return auditedSleepLlm(ctx, service, config, logger, {
|
|
487
|
+
triggerSource: "sleep",
|
|
488
|
+
operationType: "sleep_pattern",
|
|
489
|
+
modelId: `${route.provider}:${route.model}`,
|
|
490
|
+
// 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
|
|
491
|
+
relatedMemoryIds: memories.map((m) => m.id),
|
|
492
|
+
streamError: () => patternStreamFailure,
|
|
493
|
+
// 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
|
|
494
|
+
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
|
|
495
|
+
}, (reportUsage) => streamText(ctx, {
|
|
496
|
+
provider: route.provider,
|
|
497
|
+
model: route.model,
|
|
498
|
+
purpose: "sleep-pattern",
|
|
499
|
+
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
500
|
+
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
501
|
+
messages: [
|
|
502
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
503
|
+
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
504
|
+
]
|
|
505
|
+
}, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
|
|
506
|
+
};
|
|
507
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
|
|
508
|
+
if (text === undefined) {
|
|
509
|
+
if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
|
|
510
|
+
return { status: "failed", error: "llm failed" };
|
|
511
|
+
}
|
|
512
|
+
const decisions = parseJsonArray(text);
|
|
513
|
+
if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
|
|
514
|
+
// Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
|
|
515
|
+
// ev:tag pointing nowhere. Intersect evidence with the scanned set so only
|
|
516
|
+
// real memory references survive.
|
|
517
|
+
const scannedIds = new Set(memories.map((m) => m.id));
|
|
518
|
+
for (const d of decisions) {
|
|
519
|
+
if (d?.action === "create" && Array.isArray(d.evidence)) {
|
|
520
|
+
d.evidence = d.evidence.filter((id) => typeof id === "string" && scannedIds.has(id));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const snapshot = new Map();
|
|
524
|
+
const { ok, errors } = validateDecisions(decisions, snapshot, {
|
|
525
|
+
maxCreatePerRun: maxPatterns
|
|
526
|
+
});
|
|
527
|
+
if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
|
|
528
|
+
const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
|
|
529
|
+
return {
|
|
530
|
+
status: applied > 0 ? "ok" : "noop",
|
|
531
|
+
scanned: memories.length,
|
|
532
|
+
applied,
|
|
533
|
+
failures,
|
|
534
|
+
conflicts
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Phase 4 — entity relation completion. Detects orphan entities (zero
|
|
540
|
+
* relations) and completes implied relations from memory co-occurrence:
|
|
541
|
+
* entities named in the same memory → related_to; container kinds
|
|
542
|
+
* (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
|
|
543
|
+
* no LLM — cheap, so it runs even without a route. saveRelation is
|
|
544
|
+
* bookkeeping (no write hook), so it never re-triggers the scheduler.
|
|
545
|
+
*/
|
|
546
|
+
function inferRelationType(a, b) {
|
|
547
|
+
if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
|
|
548
|
+
if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
|
|
549
|
+
if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
|
|
550
|
+
return "related_to";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function phaseRelations(service, config, logger, runId, signal = null) {
|
|
554
|
+
const entities = service.listEntities({ limit: 1000 }) ?? [];
|
|
555
|
+
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
556
|
+
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
557
|
+
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
558
|
+
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
559
|
+
const seen = new Set();
|
|
560
|
+
const related = [];
|
|
561
|
+
const MAX_RELATIONS_PER_ORPHAN = 3;
|
|
562
|
+
for (const o of orphans) {
|
|
563
|
+
if (signal?.aborted) break;
|
|
564
|
+
let made = 0;
|
|
565
|
+
for (const m of memories) {
|
|
566
|
+
if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
|
|
567
|
+
const text = `${m.title ?? ""} ${m.content ?? ""}`;
|
|
568
|
+
if (!text.includes(o.name)) continue;
|
|
569
|
+
for (const other of entities) {
|
|
570
|
+
if (other.id === o.id || other.name === o.name) continue;
|
|
571
|
+
const key = [o.id, other.id].sort().join("|");
|
|
572
|
+
if (seen.has(key)) continue;
|
|
573
|
+
if (!text.includes(other.name)) continue;
|
|
574
|
+
const relationType = inferRelationType(o, other);
|
|
575
|
+
try {
|
|
576
|
+
service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
|
|
577
|
+
seen.add(key);
|
|
578
|
+
related.push({ from: o.id, to: other.id, type: relationType });
|
|
579
|
+
made++;
|
|
580
|
+
} catch (error) {
|
|
581
|
+
logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
status: related.length > 0 ? "ok" : "noop",
|
|
588
|
+
orphanCount: orphans.length,
|
|
589
|
+
related
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---------------------------------------------------------------- run
|
|
594
|
+
|
|
595
|
+
function deriveStatus(phases) {
|
|
596
|
+
const list = Object.values(phases);
|
|
597
|
+
if (list.length === 0) return "noop";
|
|
598
|
+
const anyError = list.some((p) => p.status === "failed" || p.status === "error");
|
|
599
|
+
// Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
|
|
600
|
+
// 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
|
|
601
|
+
const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
|
|
602
|
+
const anyDegraded = list.some((p) => p.status === "degraded");
|
|
603
|
+
if (anyDegraded) return "degraded";
|
|
604
|
+
if (anyWork && anyError) return "degraded";
|
|
605
|
+
if (anyError) return "failed";
|
|
606
|
+
if (anyWork) return "ok";
|
|
607
|
+
return "noop";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Run one full sleep cycle. Best-effort across all phases; writes a
|
|
612
|
+
* run_type='sleep' audit row (same dream_runs table) so sleep activity is
|
|
613
|
+
* observable alongside consolidation runs.
|
|
614
|
+
*/
|
|
615
|
+
export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
|
|
616
|
+
const runId = randomUUID();
|
|
617
|
+
// Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
|
|
618
|
+
// (LLM 各 phase)不应计入重启后的冷却窗口。
|
|
619
|
+
const sleepStartedAt = Date.now();
|
|
620
|
+
const phases = {};
|
|
621
|
+
const attempt = async (name, fn) => {
|
|
622
|
+
if (signal?.aborted) return; // user resumed activity — stop before next phase
|
|
623
|
+
try {
|
|
624
|
+
phases[name] = await fn();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
phases[name] = { status: "failed", error: error?.message ?? String(error) };
|
|
627
|
+
logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
|
|
631
|
+
await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
|
|
632
|
+
await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
|
|
633
|
+
await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
|
|
634
|
+
|
|
635
|
+
const status = deriveStatus(phases);
|
|
636
|
+
const route = resolveSleepRoute(ctx, config, logger);
|
|
637
|
+
const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
|
|
638
|
+
// Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
|
|
639
|
+
// 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
|
|
640
|
+
// 不落空数组——审计行只记真实发生过的跳过。
|
|
641
|
+
const skippedDetail = Object.entries(phases).flatMap(([phase, p]) =>
|
|
642
|
+
Array.isArray(p?.skipped) ? p.skipped.map((s) => ({ phase, ...s })) : []
|
|
643
|
+
);
|
|
644
|
+
const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
|
|
645
|
+
const receipt = buildReceipt({
|
|
646
|
+
runId,
|
|
647
|
+
status,
|
|
648
|
+
snapshotHash,
|
|
649
|
+
inputCount: 0,
|
|
650
|
+
applied: totalApplied,
|
|
651
|
+
summaryStored: false
|
|
652
|
+
});
|
|
653
|
+
try {
|
|
654
|
+
service.saveDreamRun({
|
|
655
|
+
id: runId,
|
|
656
|
+
// Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
|
|
657
|
+
// 不该计入重启后的 sleep 冷却种子。
|
|
658
|
+
created_at: new Date(sleepStartedAt).toISOString(),
|
|
659
|
+
status,
|
|
660
|
+
provider: route?.provider,
|
|
661
|
+
model: route?.model,
|
|
662
|
+
snapshot_hash: snapshotHash,
|
|
663
|
+
input_count: 0,
|
|
664
|
+
input: null,
|
|
665
|
+
decisions: phases,
|
|
666
|
+
outcome: phases,
|
|
667
|
+
applied: totalApplied,
|
|
668
|
+
summary_stored: false,
|
|
669
|
+
receipt,
|
|
670
|
+
policy_epoch: config.policyEpoch ?? 0,
|
|
671
|
+
run_type: "sleep",
|
|
672
|
+
skipped: skippedDetail.length ? skippedDetail : undefined
|
|
673
|
+
});
|
|
674
|
+
} catch (error) {
|
|
675
|
+
logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
|
|
676
|
+
}
|
|
677
|
+
return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------------------------------------------------------------- scheduler
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Idle-triggered scheduler. DSH plugins have no resident cron, so a sleep run
|
|
684
|
+
* fires when: sleep is enabled, the store has been quiet for sleepIdleMinutes,
|
|
685
|
+
* and the previous run is older than sleepMinIntervalHours. noteWrite() is
|
|
686
|
+
* called on every store write and (re)arms an idle timer that re-checks at the
|
|
687
|
+
* exact moment the idle window elapses — no polling, no cron.
|
|
688
|
+
*
|
|
689
|
+
* A `now` clock can be injected for tests; it defaults to Date.now.
|
|
690
|
+
*/
|
|
691
|
+
export function createSleepScheduler({
|
|
692
|
+
service,
|
|
693
|
+
config,
|
|
694
|
+
logger,
|
|
695
|
+
onRun = null,
|
|
696
|
+
now = () => Date.now(),
|
|
697
|
+
setTimeoutFn = setTimeout,
|
|
698
|
+
clearTimeoutFn = clearTimeout,
|
|
699
|
+
// Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
|
|
700
|
+
// 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
|
|
701
|
+
// 测试注入 0(默认)即保持旧行为。
|
|
702
|
+
lastRunAtSeed = 0
|
|
703
|
+
}) {
|
|
704
|
+
let lastWriteAt = now();
|
|
705
|
+
let lastRunAt = lastRunAtSeed;
|
|
706
|
+
let running = false;
|
|
707
|
+
let disposed = false;
|
|
708
|
+
let idleTimer = null;
|
|
709
|
+
let sleepAbort = null;
|
|
710
|
+
|
|
711
|
+
function armIdleTimer() {
|
|
712
|
+
if (disposed || idleTimer) return;
|
|
713
|
+
if (config.sleepModeEnabled !== true) return;
|
|
714
|
+
const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
|
|
715
|
+
const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
|
|
716
|
+
idleTimer = setTimeoutFn(async () => {
|
|
717
|
+
idleTimer = null;
|
|
718
|
+
// Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
|
|
719
|
+
// 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
|
|
720
|
+
const ran = await maybeSchedule();
|
|
721
|
+
if (!ran) armNextWindow();
|
|
722
|
+
}, delay);
|
|
723
|
+
idleTimer.unref?.();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Issue #187:撞 CD / run 失败后的补挂表——按「剩余 CD / 剩余静默」里更晚的
|
|
727
|
+
// 到点时刻重排一次,回调里重复同样的判断所以不会空转。跑成了一轮就不重排:
|
|
728
|
+
// 安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
|
|
729
|
+
function armNextWindow() {
|
|
730
|
+
if (disposed || idleTimer) return;
|
|
731
|
+
if (config.sleepModeEnabled !== true) return;
|
|
732
|
+
// lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
|
|
733
|
+
if (lastRunAt <= 0) return;
|
|
734
|
+
const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
|
|
735
|
+
const cdMs = (config.sleepMinIntervalHours ?? 8) * 3600000;
|
|
736
|
+
const delay = Math.max(
|
|
737
|
+
lastRunAt + cdMs - now(),
|
|
738
|
+
lastWriteAt + idleMs - now(),
|
|
739
|
+
0
|
|
740
|
+
) + 1000;
|
|
741
|
+
idleTimer = setTimeoutFn(async () => {
|
|
742
|
+
idleTimer = null;
|
|
743
|
+
const ran = await maybeSchedule();
|
|
744
|
+
if (!ran) armNextWindow();
|
|
745
|
+
}, delay);
|
|
746
|
+
idleTimer.unref?.();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function shouldRun(at = now()) {
|
|
750
|
+
if (disposed || running) return false;
|
|
751
|
+
if (config.sleepModeEnabled !== true) return false;
|
|
752
|
+
if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
|
|
753
|
+
// lastRunAt === 0 means never ran — the min-interval check must not block
|
|
754
|
+
// the very first cycle (a real run stamps a nonzero timestamp).
|
|
755
|
+
if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Called on writes: resets the idle clock and re-arms the fire timer. The
|
|
760
|
+
* pending timer is cleared first — a stale timer armed against the old idle
|
|
761
|
+
* window would otherwise fire early, fail shouldRun, and leave nothing armed
|
|
762
|
+
* for the next window (a missed trigger until the next write).
|
|
763
|
+
*
|
|
764
|
+
* While a sleep run is executing (running=true) the in-flight AbortController
|
|
765
|
+
* is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
|
|
766
|
+
* the normal write-hook path) would otherwise self-abort the cycle. External
|
|
767
|
+
* activity during the run still resets the idle clock here, so no new cycle
|
|
768
|
+
* fires until the store is quiet again. */
|
|
769
|
+
function noteWrite() {
|
|
770
|
+
lastWriteAt = now();
|
|
771
|
+
if (!running && sleepAbort) {
|
|
772
|
+
sleepAbort.abort(); // user resumed activity — interrupt an idle run
|
|
773
|
+
}
|
|
774
|
+
if (idleTimer) {
|
|
775
|
+
clearTimeoutFn(idleTimer);
|
|
776
|
+
idleTimer = null;
|
|
777
|
+
}
|
|
778
|
+
armIdleTimer();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function maybeSchedule() {
|
|
782
|
+
if (!shouldRun()) return false;
|
|
783
|
+
running = true;
|
|
784
|
+
const abort = new AbortController();
|
|
785
|
+
sleepAbort = abort;
|
|
786
|
+
try {
|
|
787
|
+
lastRunAt = now();
|
|
788
|
+
const result = await service.enqueue(() =>
|
|
789
|
+
onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
|
|
790
|
+
);
|
|
791
|
+
return !!(result && result.ok);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
|
|
794
|
+
return false;
|
|
795
|
+
} finally {
|
|
796
|
+
sleepAbort = null;
|
|
797
|
+
running = false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function dispose() {
|
|
802
|
+
disposed = true;
|
|
803
|
+
if (idleTimer) {
|
|
804
|
+
clearTimeoutFn(idleTimer);
|
|
805
|
+
idleTimer = null;
|
|
806
|
+
}
|
|
807
|
+
if (sleepAbort) {
|
|
808
|
+
sleepAbort.abort();
|
|
809
|
+
sleepAbort = null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
|
|
814
|
+
// 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
|
|
815
|
+
// 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
|
|
816
|
+
armIdleTimer();
|
|
817
|
+
|
|
818
|
+
return { noteWrite, maybeSchedule, shouldRun, dispose };
|
|
819
|
+
}
|