@elvisw/dsh-magic-context 0.0.0-stage → 0.13.0
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 +23 -0
- package/NOTICE +93 -0
- package/README.md +452 -2
- package/README.zh-CN.md +368 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +2270 -0
- package/lib/core/compartments.js +424 -0
- package/lib/core/corpus-store.js +176 -0
- package/lib/core/directive-detect.js +147 -0
- package/lib/core/domain-reconcile.js +394 -0
- package/lib/core/domains.js +127 -0
- package/lib/core/embedding-local.js +247 -0
- package/lib/core/global-autostage.js +223 -0
- package/lib/core/global-domains.js +302 -0
- package/lib/core/journal.js +248 -0
- package/lib/core/llm-deadline.js +53 -0
- package/lib/core/machine-domain.js +106 -0
- package/lib/core/maintenance.js +380 -0
- package/lib/core/memory-bridge.js +818 -0
- package/lib/core/memory-categories.js +84 -0
- package/lib/core/memory-scoring.js +65 -0
- package/lib/core/memory-search.js +414 -0
- package/lib/core/memory-write.js +1176 -0
- package/lib/core/policy.js +111 -0
- package/lib/core/profile-confirmation.js +55 -0
- package/lib/core/profile-review.js +375 -0
- package/lib/core/profile-write.js +283 -0
- package/lib/core/reference-seeds.js +63 -0
- package/lib/core/route-health.js +106 -0
- package/lib/core/stores.js +1021 -0
- package/lib/core/tagger.js +7 -0
- package/lib/core/types.js +15 -0
- package/lib/core/vector-backfill.js +140 -0
- package/lib/core/vector-lane.js +184 -0
- package/lib/dsh/command-surface.js +376 -0
- package/lib/dsh/compaction-engine.js +231 -0
- package/lib/dsh/config-switches.js +56 -0
- package/lib/dsh/config.js +13 -0
- package/lib/dsh/context-pressure.js +36 -0
- package/lib/dsh/drop-landing.js +42 -0
- package/lib/dsh/emergency.js +89 -0
- package/lib/dsh/flush-command.js +148 -0
- package/lib/dsh/global-review.js +412 -0
- package/lib/dsh/historian.js +907 -0
- package/lib/dsh/live-journal.js +81 -0
- package/lib/dsh/maintenance-wiring.js +229 -0
- package/lib/dsh/management-pass.js +564 -0
- package/lib/dsh/memory-lane.js +535 -0
- package/lib/dsh/pre-step.js +675 -0
- package/lib/dsh/project-drift.js +116 -0
- package/lib/dsh/recomp.js +192 -0
- package/lib/dsh/reduce-guidance.js +188 -0
- package/lib/dsh/seams.js +588 -0
- package/lib/dsh/search-hint.js +186 -0
- package/lib/dsh/session-notice.js +100 -0
- package/lib/dsh/status-command.js +75 -0
- package/lib/dsh/telemetry.js +349 -0
- package/lib/dsh/tool-bridge.js +1009 -0
- package/lib/dsh/volatile.js +89 -0
- package/lib/index.js +931 -0
- package/package.json +108 -4
- package/tools/migrate-profile-package-name.mjs +317 -0
- package/vendor/onnx/LICENSE +27 -0
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnx/ort.node.min.mjs +8 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WP10 — the `machine` domain: what every project on THIS machine can share,
|
|
3
|
+
* with an operator-supplied exclusion list.
|
|
4
|
+
*
|
|
5
|
+
* It is a domain like the other two — same identity shape, same peer lane, same
|
|
6
|
+
* cap derivation — so nothing in recall, search or the watermark needs to know
|
|
7
|
+
* it exists. What is specific to it is only WHO the members are: this machine's
|
|
8
|
+
* projects, minus the ones the operator keeps out.
|
|
9
|
+
*
|
|
10
|
+
* Two decisions carry the design:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Membership is added, never inferred away.** The identities this process
|
|
13
|
+
* has seen are a PARTIAL view — it has served some projects, not all — so
|
|
14
|
+
* "not seen" can never mean "not a member". Only an explicit exclusion
|
|
15
|
+
* removes a member. Getting this wrong would delete a colleague's project
|
|
16
|
+
* from the domain because nobody opened it this hour.
|
|
17
|
+
* 2. **Disabling is reversible and never destructive.** Turning the switch off
|
|
18
|
+
* flips the domain's `enabled` flag and leaves the membership rows alone, so
|
|
19
|
+
* switching it back on restores exactly what was there. The flag is what the
|
|
20
|
+
* activation function reads, so a disabled domain is genuinely invisible
|
|
21
|
+
* rather than merely unused.
|
|
22
|
+
*/
|
|
23
|
+
import { DEFAULT_DOMAIN_BUDGET_TOKENS, defaultDomainCap, domainIdentity, } from "./domains.js";
|
|
24
|
+
/**
|
|
25
|
+
* The machine domain's BIRTH allowlist (2026-10-12, user verdict "A"): native
|
|
26
|
+
* machine facts are environment/configuration constraints, so the contract's
|
|
27
|
+
* conservative `["CONSTRAINTS"]` default forced every one of them to masquerade
|
|
28
|
+
* as CONSTRAINTS to publish at all (#1515 did, #1537 was refused). Scope is the
|
|
29
|
+
* v2 FIVE share categories (the only values `parseShareCategories` accepts);
|
|
30
|
+
* `ENVIRONMENT` exists in the 8-category decay table but is NOT a share
|
|
31
|
+
* category. Applies only to NEW domains — an existing row's hand-edited
|
|
32
|
+
* allowlist survives every sync, and the live row was widened by the same
|
|
33
|
+
* verdict.
|
|
34
|
+
*/
|
|
35
|
+
export const MACHINE_DEFAULT_SHARE_CATEGORIES = [
|
|
36
|
+
'CONSTRAINTS',
|
|
37
|
+
'CONFIG_VALUES',
|
|
38
|
+
'ARCHITECTURE',
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Decide the machine domain's next state. Pure, so the membership rules are
|
|
42
|
+
* testable without a store.
|
|
43
|
+
*/
|
|
44
|
+
export function planMachineDomain(input) {
|
|
45
|
+
const enabled = input.config.enabled === true;
|
|
46
|
+
const excluded = new Set(input.config.excludeProjects ?? []);
|
|
47
|
+
const existing = [...new Set(input.existingMembers)];
|
|
48
|
+
if (!enabled) {
|
|
49
|
+
// While off, membership is not touched: the only thing that changes is the
|
|
50
|
+
// flag (applied by the caller), because off must be as reversible as on.
|
|
51
|
+
return { enabled: false, members: existing, add: [], remove: [], changed: false };
|
|
52
|
+
}
|
|
53
|
+
const target = [...new Set(input.identities.filter(identity => !excluded.has(identity)))].sort();
|
|
54
|
+
const targetSet = new Set(target);
|
|
55
|
+
const existingSet = new Set(existing);
|
|
56
|
+
const add = target.filter(identity => !existingSet.has(identity));
|
|
57
|
+
// Only explicit exclusions remove a member — never a partial view of identity.
|
|
58
|
+
const remove = existing.filter(identity => excluded.has(identity));
|
|
59
|
+
return {
|
|
60
|
+
enabled: true,
|
|
61
|
+
members: [...new Set([...target, ...existing.filter(identity => !excluded.has(identity))])].sort(),
|
|
62
|
+
add,
|
|
63
|
+
remove,
|
|
64
|
+
changed: add.length > 0 || remove.length > 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Apply the plan. Idempotent: a second call with the same inputs reports
|
|
69
|
+
* `changed: false` and writes nothing, so it is safe to run on every pass.
|
|
70
|
+
*/
|
|
71
|
+
export function syncMachineDomain(stores, input) {
|
|
72
|
+
const domainId = domainIdentity('machine');
|
|
73
|
+
const existing = stores.getDomain(domainId);
|
|
74
|
+
const enabled = input.config.enabled === true;
|
|
75
|
+
// Nothing configured and nothing remembered: not even a row to keep in sync.
|
|
76
|
+
if (existing === undefined && !enabled) {
|
|
77
|
+
return { enabled: false, members: [], add: [], remove: [], changed: false };
|
|
78
|
+
}
|
|
79
|
+
const existingMembers = stores.listDomainMembers(domainId).map(member => member.identity);
|
|
80
|
+
const plan = planMachineDomain({
|
|
81
|
+
identities: input.identities,
|
|
82
|
+
existingMembers,
|
|
83
|
+
config: input.config,
|
|
84
|
+
});
|
|
85
|
+
if (existing === undefined) {
|
|
86
|
+
// Born with the derived cap and the contract's default allowlist, exactly
|
|
87
|
+
// like an imported domain — the operator can still edit both afterwards.
|
|
88
|
+
stores.upsertDomain({
|
|
89
|
+
domainId,
|
|
90
|
+
kind: 'machine',
|
|
91
|
+
enabled: plan.enabled,
|
|
92
|
+
budgetTokens: DEFAULT_DOMAIN_BUDGET_TOKENS.machine,
|
|
93
|
+
cap: defaultDomainCap('machine'),
|
|
94
|
+
shareCategories: MACHINE_DEFAULT_SHARE_CATEGORIES,
|
|
95
|
+
}, input.now);
|
|
96
|
+
}
|
|
97
|
+
else if (existing.enabled !== plan.enabled) {
|
|
98
|
+
// Flip only the switch: a hand-edited cap or allowlist must survive a sync.
|
|
99
|
+
stores.setDomainEnabled(domainId, plan.enabled, input.now);
|
|
100
|
+
}
|
|
101
|
+
for (const identity of plan.add)
|
|
102
|
+
stores.addDomainMember(domainId, identity, input.now);
|
|
103
|
+
for (const identity of plan.remove)
|
|
104
|
+
stores.removeDomainMember(domainId, identity);
|
|
105
|
+
return plan;
|
|
106
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { memoryContentHash } from "./memory-write.js";
|
|
2
|
+
import { LlmDeadlineError, withDeadline } from "./llm-deadline.js";
|
|
3
|
+
/**
|
|
4
|
+
* Process-memory sample for the tick's `memory-sample` journal row.
|
|
5
|
+
*
|
|
6
|
+
* The host is long-lived, and the only leak class that ever bit upstream MC in
|
|
7
|
+
* this shape (0.49.1 #681) was "every completed pass kept the previous pass plus
|
|
8
|
+
* its full message history". Our static audit (2026-10-15) found no such
|
|
9
|
+
* retention, so a once-per-tick series is what keeps a future regression
|
|
10
|
+
* visible: rising RSS across days, with no profiler and no extra requests.
|
|
11
|
+
* Pure so the unit test can pin the rounding exactly.
|
|
12
|
+
*/
|
|
13
|
+
export function memorySample(usage) {
|
|
14
|
+
const mb = (bytes) => Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
15
|
+
return {
|
|
16
|
+
rssMb: mb(usage.rss),
|
|
17
|
+
heapUsedMb: mb(usage.heapUsed),
|
|
18
|
+
heapTotalMb: mb(usage.heapTotal),
|
|
19
|
+
externalMb: mb(usage.external),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** TTL rows archived per tick; the list is already capped by `listExpired`. */
|
|
23
|
+
const EXPIRE_BATCH = 200;
|
|
24
|
+
/** Unverified rows judged per tick — an LLM call per tick. Sized against the
|
|
25
|
+
* observed promotion inflow (peak ~165 rows/day on 2026-10-09): at the 30-min
|
|
26
|
+
* cadence the old 3/tick capped the sweep at 144 rows/day — the ceiling
|
|
27
|
+
* itself. 16 keeps ~6× headroom at a trivial prompt size (16 × 400-char
|
|
28
|
+
* previews). */
|
|
29
|
+
const VERIFY_BATCH = 16;
|
|
30
|
+
/** Per-row content budget in the verification prompt. */
|
|
31
|
+
const CONTENT_PREVIEW = 400;
|
|
32
|
+
/** Output cap. Reasoning is billed against it and a heavy thinking pass can
|
|
33
|
+
* consume the whole budget before emitting any text (measured: 8192 produced
|
|
34
|
+
* zero deltas on one tick and three verdicts on another). 32768 is the
|
|
35
|
+
* historian's ceiling — the same floor/ceiling reasoning applies here. */
|
|
36
|
+
const VERIFY_MAX_TOKENS = 32768;
|
|
37
|
+
/** Wall-clock budget for one verify round trip (upstream MC's dreamer applies a
|
|
38
|
+
* per-batch deadline for the same reason: a provider stream that never settles
|
|
39
|
+
* must not own the lane's only execution slot). Generous against observed calls
|
|
40
|
+
* (a few seconds), strict enough that a hung one lands in the next tick. */
|
|
41
|
+
const VERIFY_TIMEOUT_MS = 120_000;
|
|
42
|
+
/**
|
|
43
|
+
* Rows read from the unverified window per tick, before the skip filter.
|
|
44
|
+
*
|
|
45
|
+
* The window itself is a SQL LIMIT (see {@link listMemoriesSql} in
|
|
46
|
+
* memory-write.ts), so a row the model could not judge must not simply occupy a
|
|
47
|
+
* slot forever: `skip` verdicts are remembered in `metadata_json` and filtered
|
|
48
|
+
* AFTER the window read but BEFORE `VERIFY_BATCH` is taken. The scan window is
|
|
49
|
+
* 8× the batch so a realistic run of skips still leaves a full batch of live
|
|
50
|
+
* candidates; it stays one cheap indexed read either way.
|
|
51
|
+
*/
|
|
52
|
+
const VERIFY_SCAN = VERIFY_BATCH * 8;
|
|
53
|
+
const VERIFY_INSTRUCTION = [
|
|
54
|
+
'You are auditing project memories for a coding agent. For each memory, judge whether it is still true and useful for this project:',
|
|
55
|
+
'- "verified": still accurate and worth keeping.',
|
|
56
|
+
'- "stale": outdated or partly wrong; provide the corrected "content" (same style, self-contained).',
|
|
57
|
+
'- "wrong": plain incorrect or harmful; provide a short "reason".',
|
|
58
|
+
'- "skip": cannot judge from the text alone.',
|
|
59
|
+
'Also score every memory with "importance" (1-100): how load-bearing is it for future sessions? Roughly: 80+ project rules/constraints, 60-79 architecture/config facts, 40-59 routine context, under 40 trivia.',
|
|
60
|
+
'Judge only from general software-engineering plausibility and internal consistency; you have no file access.',
|
|
61
|
+
'Reply with ONLY a JSON object: {"verdicts":[{"id":<number>,"verdict":"verified"|"stale"|"wrong"|"skip","importance"?:number,"content"?:string,"reason"?:string}]}',
|
|
62
|
+
].join('\n');
|
|
63
|
+
/** Salvage complete `{...}` objects from a truncated verdict array. A reasoning
|
|
64
|
+
* model that runs out of output mid-array still yields usable leading verdicts;
|
|
65
|
+
* discarding the whole call would just retry the same truncation next tick. */
|
|
66
|
+
function salvageObjects(inner) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let depth = 0;
|
|
69
|
+
let start = -1;
|
|
70
|
+
let inString = false;
|
|
71
|
+
let escaped = false;
|
|
72
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
73
|
+
const ch = inner[i];
|
|
74
|
+
if (inString) {
|
|
75
|
+
if (escaped)
|
|
76
|
+
escaped = false;
|
|
77
|
+
else if (ch === '\\')
|
|
78
|
+
escaped = true;
|
|
79
|
+
else if (ch === '"')
|
|
80
|
+
inString = false;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '"') {
|
|
84
|
+
inString = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === '{') {
|
|
88
|
+
if (depth === 0)
|
|
89
|
+
start = i;
|
|
90
|
+
depth += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === '}') {
|
|
94
|
+
depth -= 1;
|
|
95
|
+
if (depth === 0 && start >= 0) {
|
|
96
|
+
try {
|
|
97
|
+
out.push(JSON.parse(inner.slice(start, i + 1)));
|
|
98
|
+
}
|
|
99
|
+
catch { /* skip malformed object */ }
|
|
100
|
+
start = -1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** Extract the first JSON object from a model reply (tolerates prose fences). */
|
|
107
|
+
function parseVerdicts(text) {
|
|
108
|
+
const start = text.indexOf('{');
|
|
109
|
+
const end = text.lastIndexOf('}');
|
|
110
|
+
if (start < 0 || end <= start)
|
|
111
|
+
return [];
|
|
112
|
+
const slice = text.slice(start, end + 1);
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(slice);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
const arrayStart = slice.indexOf('[');
|
|
119
|
+
if (arrayStart < 0)
|
|
120
|
+
return [];
|
|
121
|
+
parsed = { verdicts: salvageObjects(slice.slice(arrayStart + 1)) };
|
|
122
|
+
}
|
|
123
|
+
const verdicts = parsed.verdicts;
|
|
124
|
+
if (!Array.isArray(verdicts))
|
|
125
|
+
return [];
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const item of verdicts) {
|
|
128
|
+
const row = item;
|
|
129
|
+
if (typeof row.id !== 'number' || typeof row.verdict !== 'string')
|
|
130
|
+
continue;
|
|
131
|
+
out.push({
|
|
132
|
+
id: row.id,
|
|
133
|
+
verdict: row.verdict,
|
|
134
|
+
...(typeof row.importance === 'number' && Number.isFinite(row.importance) ? { importance: row.importance } : {}),
|
|
135
|
+
...(typeof row.content === 'string' && row.content.trim().length > 0 ? { content: row.content.trim() } : {}),
|
|
136
|
+
...(typeof row.reason === 'string' && row.reason.trim().length > 0 ? { reason: row.reason.trim() } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** One LLM round trip. Returns the raw text, or the reason it produced none —
|
|
142
|
+
* the reason is journaled (a bare "verify call failed" hid six live ticks
|
|
143
|
+
* worth of root cause). The provider's usage sample rides along when the
|
|
144
|
+
* stream reports one: it is the dream token anchor the patrol watches. */
|
|
145
|
+
async function collectText(llm, deps, userPrompt) {
|
|
146
|
+
const budgetMs = deps.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
147
|
+
try {
|
|
148
|
+
return await withDeadline(async (signal) => {
|
|
149
|
+
const byIndex = new Map();
|
|
150
|
+
let truncated = false;
|
|
151
|
+
let usage;
|
|
152
|
+
const chunks = llm.stream({
|
|
153
|
+
provider: deps.route.provider,
|
|
154
|
+
model: deps.route.model,
|
|
155
|
+
// The DSH llm seam takes content BLOCKS, not a bare string (the host
|
|
156
|
+
// calls content.some(...) — a string threw "content.some is not a
|
|
157
|
+
// function" and failed every tick silently).
|
|
158
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: userPrompt }] }],
|
|
159
|
+
maxTokens: VERIFY_MAX_TOKENS,
|
|
160
|
+
sessionId: deps.sessionId,
|
|
161
|
+
purpose: 'compaction',
|
|
162
|
+
signal,
|
|
163
|
+
});
|
|
164
|
+
for await (const raw of chunks) {
|
|
165
|
+
const chunk = raw;
|
|
166
|
+
if (chunk.type === 'text-delta' && typeof chunk.index === 'number' && typeof chunk.text === 'string') {
|
|
167
|
+
byIndex.set(chunk.index, (byIndex.get(chunk.index) ?? '') + chunk.text);
|
|
168
|
+
}
|
|
169
|
+
else if (chunk.type === 'usage') {
|
|
170
|
+
// Same capture as the historian's collector: keep the numeric fields.
|
|
171
|
+
const reported = raw.usage;
|
|
172
|
+
const numeric = {};
|
|
173
|
+
for (const [key, value] of Object.entries(reported ?? {})) {
|
|
174
|
+
if (typeof value === 'number')
|
|
175
|
+
numeric[key] = value;
|
|
176
|
+
}
|
|
177
|
+
usage = numeric;
|
|
178
|
+
}
|
|
179
|
+
else if (chunk.type === 'finish') {
|
|
180
|
+
// Same terminal-reason semantics as the historian's collector:
|
|
181
|
+
// error/aborted fail with the failure detail. max-tokens is NOT fatal
|
|
182
|
+
// here: the verdict list is a flat JSON array, so whatever objects made
|
|
183
|
+
// it out before the cap are still worth banking (MC's "a timed-out
|
|
184
|
+
// batch banks what it verified"); rows without a verdict stay
|
|
185
|
+
// unverified and are retried on a later tick.
|
|
186
|
+
const reason = raw.reason;
|
|
187
|
+
if (reason !== undefined && (reason.kind === 'error' || reason.kind === 'aborted')) {
|
|
188
|
+
const detail = reason.failure?.message ?? reason.failure?.code;
|
|
189
|
+
return { ok: false, why: `stream finished (${reason.kind})${detail === undefined ? '' : `: ${detail}`}` };
|
|
190
|
+
}
|
|
191
|
+
if (reason?.kind === 'max-tokens')
|
|
192
|
+
truncated = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const text = [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, value]) => value).join('');
|
|
196
|
+
if (text.length === 0) {
|
|
197
|
+
return { ok: false, why: truncated ? 'stream hit max-tokens with no text' : 'stream produced no text deltas' };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, text, truncated, ...(usage === undefined ? {} : { usage }) };
|
|
200
|
+
}, budgetMs);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
// A spent budget is reported like any other failed call (journaled by the
|
|
204
|
+
// caller, retried next tick) — the point is that it is reported at all.
|
|
205
|
+
return { ok: false, why: error instanceof LlmDeadlineError ? `deadline ${error.message}` : error instanceof Error ? error.message : String(error) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function renderRow(row, nowMs) {
|
|
209
|
+
const ageDays = Math.max(0, Math.round((nowMs - row.createdAt) / 86_400_000));
|
|
210
|
+
const content = row.content.length > CONTENT_PREVIEW ? row.content.slice(0, CONTENT_PREVIEW) + '…' : row.content;
|
|
211
|
+
return `- id ${String(row.id)} [${row.category}] (age ${String(ageDays)}d): ${content.replace(/\s+/g, ' ')}`;
|
|
212
|
+
}
|
|
213
|
+
/** Run one maintenance tick. Never throws — every failure is journaled. */
|
|
214
|
+
export async function runMaintenanceTick(deps) {
|
|
215
|
+
const now = (deps.now ?? Date.now)();
|
|
216
|
+
const writer = deps.writer;
|
|
217
|
+
const identity = deps.identity;
|
|
218
|
+
const tally = { expired: 0, verified: 0, updated: 0, archived: 0, verifyRan: false };
|
|
219
|
+
if (!writer || !identity)
|
|
220
|
+
return { ...tally, reason: 'writer-unavailable' };
|
|
221
|
+
if (writer.status.readOnly === true)
|
|
222
|
+
return { ...tally, reason: 'read-only' };
|
|
223
|
+
// 1) TTL expiry — archive is the contract action, so the mutation log and
|
|
224
|
+
// authority guard stay authoritative even in bulk maintenance.
|
|
225
|
+
for (const id of writer.listExpired(identity, now, EXPIRE_BATCH)) {
|
|
226
|
+
const result = writer.archiveMemory({ identity, id, reason: 'ttl-expired' });
|
|
227
|
+
if (result.ok)
|
|
228
|
+
tally.expired += 1;
|
|
229
|
+
}
|
|
230
|
+
// 2) Verification — configured route or nothing (no session to inherit).
|
|
231
|
+
if (!deps.llm || !deps.route) {
|
|
232
|
+
const pool = writer.stats(identity).unverified;
|
|
233
|
+
deps.journal.info('maintenance', `maintenance: expired ${String(tally.expired)} (verify off: no llm route; pool ${String(pool)})`, {
|
|
234
|
+
expired: tally.expired,
|
|
235
|
+
poolUnverified: pool,
|
|
236
|
+
verify: 'off',
|
|
237
|
+
});
|
|
238
|
+
return { ...tally };
|
|
239
|
+
}
|
|
240
|
+
// The unverified filter runs in SQL (before the limit): filtering a fixed
|
|
241
|
+
// render window instead let the sweep stall once its first rows were
|
|
242
|
+
// verified, because verification deliberately leaves `updated_at` alone and
|
|
243
|
+
// those rows keep their window slots. Recurrence (seen_count ≥ 2) jumps the
|
|
244
|
+
// queue — see listMemoriesSql.
|
|
245
|
+
//
|
|
246
|
+
// Second stall guard (2026-11-11): a `skip` verdict ("cannot judge from the
|
|
247
|
+
// text alone") used to leave NO trace, so the same rows were asked again on
|
|
248
|
+
// every tick — and with ≥ VERIFY_BATCH such rows the sweep would circle
|
|
249
|
+
// forever while every tick paid for an LLM call. The verdict is now remembered
|
|
250
|
+
// in `metadata_json` (content hash), and remembered rows are stepped over
|
|
251
|
+
// here. The hash is compared against the CURRENT content, so any edit (a
|
|
252
|
+
// `stale` rewrite, a human edit, a re-promotion) puts the row back in the
|
|
253
|
+
// pool automatically.
|
|
254
|
+
const window = writer.listMemories(identity, { limit: VERIFY_SCAN, now, unverifiedOnly: true });
|
|
255
|
+
const skipHashes = window.length === 0
|
|
256
|
+
? undefined
|
|
257
|
+
: writer.verifySkipHashes(identity, window.map(row => row.id));
|
|
258
|
+
const candidates = [];
|
|
259
|
+
let rememberedSkips = 0;
|
|
260
|
+
for (const row of window) {
|
|
261
|
+
if (skipHashes?.get(row.id) === memoryContentHash(row.content)) {
|
|
262
|
+
rememberedSkips += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
candidates.push(row);
|
|
266
|
+
if (candidates.length === VERIFY_BATCH)
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (candidates.length === 0) {
|
|
270
|
+
deps.journal.info('maintenance', `maintenance: expired ${String(tally.expired)} (nothing to verify)`, {
|
|
271
|
+
expired: tally.expired,
|
|
272
|
+
poolUnverified: 0,
|
|
273
|
+
...rememberedSkips === 0 ? {} : { rememberedSkips },
|
|
274
|
+
verify: 'idle',
|
|
275
|
+
});
|
|
276
|
+
return { ...tally };
|
|
277
|
+
}
|
|
278
|
+
const prompt = VERIFY_INSTRUCTION
|
|
279
|
+
+ '\n\nProject identity: ' + identity + '\nMemories:\n'
|
|
280
|
+
+ candidates.map(row => renderRow(row, now)).join('\n');
|
|
281
|
+
const call = await collectText(deps.llm, deps, prompt);
|
|
282
|
+
if (!call.ok) {
|
|
283
|
+
deps.journal.warn('maintenance', `maintenance: verify call failed (retried next tick): ${call.why}`, {
|
|
284
|
+
candidates: candidates.length,
|
|
285
|
+
why: call.why,
|
|
286
|
+
route: `${deps.route.provider}/${deps.route.model}`,
|
|
287
|
+
verify: 'failed',
|
|
288
|
+
});
|
|
289
|
+
return { ...tally, reason: 'llm-failed' };
|
|
290
|
+
}
|
|
291
|
+
const text = call.text;
|
|
292
|
+
const verdicts = parseVerdicts(text);
|
|
293
|
+
// Dream token anchor: the same {inputTokens,outputTokens} shape the
|
|
294
|
+
// historian banks, so mc-report parses both lanes identically.
|
|
295
|
+
const usage = call.usage !== undefined
|
|
296
|
+
? { inputTokens: call.usage.inputTokens ?? 0, outputTokens: call.usage.outputTokens ?? 0 }
|
|
297
|
+
: undefined;
|
|
298
|
+
const verifiedIds = [];
|
|
299
|
+
const scored = [];
|
|
300
|
+
const lagMs = [];
|
|
301
|
+
const known = new Map(candidates.map(row => [row.id, row]));
|
|
302
|
+
let skipped = 0;
|
|
303
|
+
for (const verdict of verdicts) {
|
|
304
|
+
const row = known.get(verdict.id);
|
|
305
|
+
if (row === undefined)
|
|
306
|
+
continue;
|
|
307
|
+
// A score is independent of the verdict's direction — bank it whenever the
|
|
308
|
+
// model offered one; classifyMemories self-filters archived/foreign rows.
|
|
309
|
+
if (verdict.importance !== undefined)
|
|
310
|
+
scored.push({ id: verdict.id, importance: verdict.importance });
|
|
311
|
+
if (verdict.verdict === 'verified') {
|
|
312
|
+
verifiedIds.push(verdict.id);
|
|
313
|
+
lagMs.push(now - row.createdAt);
|
|
314
|
+
}
|
|
315
|
+
else if (verdict.verdict === 'stale' && verdict.content !== undefined) {
|
|
316
|
+
const result = writer.updateMemory({ identity, id: verdict.id, content: verdict.content });
|
|
317
|
+
if (result.ok) {
|
|
318
|
+
tally.updated += 1;
|
|
319
|
+
lagMs.push(now - row.createdAt);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else if (verdict.verdict === 'wrong') {
|
|
323
|
+
const result = writer.archiveMemory({ identity, id: verdict.id, reason: 'verify-wrong' });
|
|
324
|
+
if (result.ok) {
|
|
325
|
+
tally.archived += 1;
|
|
326
|
+
lagMs.push(now - row.createdAt);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (verdict.verdict === 'skip') {
|
|
330
|
+
skipped += 1;
|
|
331
|
+
// Remember it (metadata only, no status change: a skip stays
|
|
332
|
+
// inconclusive). Keyed by content hash so a later edit re-opens the row.
|
|
333
|
+
writer.recordVerifySkip({
|
|
334
|
+
identity,
|
|
335
|
+
id: verdict.id,
|
|
336
|
+
contentHash: memoryContentHash(row.content),
|
|
337
|
+
at: now,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (verifiedIds.length > 0)
|
|
342
|
+
tally.verified += writer.markVerified(identity, verifiedIds, now);
|
|
343
|
+
// Classify scoring rides the same verdict round trip (MC's classify slice):
|
|
344
|
+
// metadata-only, applied AFTER the content verdicts so a stale rewrite is
|
|
345
|
+
// scored on its corrected text and archived rows self-filter through the
|
|
346
|
+
// status clause. Feed m[0]'s importance ranking (memory-selection order).
|
|
347
|
+
const scoredApplied = scored.length > 0 ? writer.classifyMemories(identity, scored, now) : 0;
|
|
348
|
+
tally.verifyRan = true;
|
|
349
|
+
// Telemetry anchors for the memory lane (mc-report aggregates these):
|
|
350
|
+
// verification lag and the unverified pool size are the staging trigger
|
|
351
|
+
// lines, and wrong-rate comes from archived/verdicts.
|
|
352
|
+
const lagHours = lagMs.map(ms => ms / 3_600_000);
|
|
353
|
+
const lagMaxH = lagHours.length > 0 ? Math.max(...lagHours) : undefined;
|
|
354
|
+
const lagAvgH = lagHours.length > 0 ? lagHours.reduce((sum, value) => sum + value, 0) / lagHours.length : undefined;
|
|
355
|
+
const round1 = (value) => Math.round(value * 10) / 10;
|
|
356
|
+
const pool = writer.stats(identity).unverified;
|
|
357
|
+
deps.journal.info('maintenance', `maintenance: expired ${String(tally.expired)} · verified ${String(tally.verified)}`
|
|
358
|
+
+ ` · updated ${String(tally.updated)} · archived ${String(tally.archived)}`
|
|
359
|
+
+ ` · scored ${String(scoredApplied)} · pool ${String(pool)}`
|
|
360
|
+
+ (usage !== undefined ? ` · tokens ${String(usage.inputTokens)}/${String(usage.outputTokens)}` : '')
|
|
361
|
+
+ (call.truncated ? ` (truncated: banked ${String(verdicts.length)}/${String(candidates.length)} verdicts)` : ''), {
|
|
362
|
+
expired: tally.expired,
|
|
363
|
+
verified: tally.verified,
|
|
364
|
+
updated: tally.updated,
|
|
365
|
+
archived: tally.archived,
|
|
366
|
+
scored: scoredApplied,
|
|
367
|
+
skipped,
|
|
368
|
+
candidates: candidates.length,
|
|
369
|
+
verdicts: verdicts.length,
|
|
370
|
+
poolUnverified: pool,
|
|
371
|
+
verify: 'ran',
|
|
372
|
+
...rememberedSkips === 0 ? {} : { rememberedSkips },
|
|
373
|
+
...(usage !== undefined ? { usage } : {}),
|
|
374
|
+
...(lagMaxH !== undefined ? { lagMaxH: round1(lagMaxH) } : {}),
|
|
375
|
+
...(lagAvgH !== undefined ? { lagAvgH: round1(lagAvgH) } : {}),
|
|
376
|
+
...call.truncated ? { truncated: true } : {},
|
|
377
|
+
route: `${deps.route.provider}/${deps.route.model}`,
|
|
378
|
+
});
|
|
379
|
+
return { ...tally };
|
|
380
|
+
}
|