@elvisw/dsh-magic-context 0.0.0-stage → 0.14.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.
Files changed (66) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE +93 -0
  3. package/README.md +501 -2
  4. package/README.zh-CN.md +398 -0
  5. package/cordis.patch.yml +20 -0
  6. package/lib/client.js +2753 -0
  7. package/lib/core/compartments.js +424 -0
  8. package/lib/core/corpus-store.js +176 -0
  9. package/lib/core/directive-detect.js +147 -0
  10. package/lib/core/domain-reconcile.js +394 -0
  11. package/lib/core/domains.js +127 -0
  12. package/lib/core/embedding-local.js +247 -0
  13. package/lib/core/global-autostage.js +223 -0
  14. package/lib/core/global-domains.js +302 -0
  15. package/lib/core/journal.js +248 -0
  16. package/lib/core/llm-deadline.js +53 -0
  17. package/lib/core/machine-domain.js +106 -0
  18. package/lib/core/maintenance.js +380 -0
  19. package/lib/core/memory-bridge.js +818 -0
  20. package/lib/core/memory-categories.js +84 -0
  21. package/lib/core/memory-scoring.js +65 -0
  22. package/lib/core/memory-search.js +414 -0
  23. package/lib/core/memory-write.js +1176 -0
  24. package/lib/core/policy.js +111 -0
  25. package/lib/core/profile-confirmation.js +55 -0
  26. package/lib/core/profile-review.js +375 -0
  27. package/lib/core/profile-write.js +283 -0
  28. package/lib/core/reference-seeds.js +63 -0
  29. package/lib/core/route-health.js +106 -0
  30. package/lib/core/stores.js +1021 -0
  31. package/lib/core/tagger.js +7 -0
  32. package/lib/core/types.js +15 -0
  33. package/lib/core/vector-backfill.js +140 -0
  34. package/lib/core/vector-lane.js +184 -0
  35. package/lib/dsh/command-surface.js +376 -0
  36. package/lib/dsh/compaction-engine.js +231 -0
  37. package/lib/dsh/config-switches.js +56 -0
  38. package/lib/dsh/config.js +13 -0
  39. package/lib/dsh/context-pressure.js +36 -0
  40. package/lib/dsh/drop-landing.js +42 -0
  41. package/lib/dsh/emergency.js +89 -0
  42. package/lib/dsh/flush-command.js +148 -0
  43. package/lib/dsh/global-review.js +412 -0
  44. package/lib/dsh/historian.js +907 -0
  45. package/lib/dsh/live-journal.js +81 -0
  46. package/lib/dsh/maintenance-wiring.js +229 -0
  47. package/lib/dsh/management-pass.js +564 -0
  48. package/lib/dsh/memory-lane.js +535 -0
  49. package/lib/dsh/pre-step.js +675 -0
  50. package/lib/dsh/project-drift.js +116 -0
  51. package/lib/dsh/recomp.js +192 -0
  52. package/lib/dsh/reduce-guidance.js +188 -0
  53. package/lib/dsh/seams.js +588 -0
  54. package/lib/dsh/search-hint.js +186 -0
  55. package/lib/dsh/session-notice.js +100 -0
  56. package/lib/dsh/status-command.js +75 -0
  57. package/lib/dsh/telemetry.js +349 -0
  58. package/lib/dsh/tool-bridge.js +1009 -0
  59. package/lib/dsh/volatile.js +89 -0
  60. package/lib/index.js +931 -0
  61. package/package.json +108 -4
  62. package/tools/migrate-profile-package-name.mjs +317 -0
  63. package/vendor/onnx/LICENSE +27 -0
  64. package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
  65. package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
  66. package/vendor/onnx/ort.node.min.mjs +8 -0
@@ -0,0 +1,111 @@
1
+ export const DEFAULT_POLICY = {
2
+ executeThreshold: 65,
3
+ forceThreshold: 85,
4
+ assumedContextWindow: 256_000,
5
+ ttlMs: 5 * 60_000,
6
+ protectedToolResults: 8,
7
+ historianProtectedNodes: 8,
8
+ historianMinNodes: 3,
9
+ historianTimeoutMs: 120_000,
10
+ historianMaxTokens: 8192,
11
+ historianMaxTokensCeiling: 32_768,
12
+ historianAssumedWindow: 128_000,
13
+ historianMinBudgetTokens: 5_000,
14
+ baselineTokens: 2000,
15
+ memoryBudgetTokens: 8000,
16
+ memoryLimit: 40,
17
+ forceWaitReadyMs: 0,
18
+ };
19
+ /**
20
+ * The request's total input tokens as the provider counts them against the
21
+ * window: the DSH TokenUsage splits input into uncached input plus cache
22
+ * read/write, so pressure must sum all three (MC's OpenCode usage figure is
23
+ * already the total; this is the same quantity reassembled).
24
+ */
25
+ export function totalInputTokens(usage) {
26
+ return (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
27
+ }
28
+ /** Usage percent against the model's context window, or undefined when unknown. */
29
+ export function pressurePercent(latest, contextWindow) {
30
+ if (latest === undefined || contextWindow === undefined || contextWindow <= 0)
31
+ return undefined;
32
+ const total = totalInputTokens(latest);
33
+ if (total <= 0)
34
+ return 0;
35
+ return (total / contextWindow) * 100;
36
+ }
37
+ /**
38
+ * The denominator for pressure math: the window the routed model disclosed, or
39
+ * {@link PressurePolicy.assumedContextWindow} when it disclosed none (also for
40
+ * a placeholder 0 or a non-finite reading).
41
+ *
42
+ * Every pressure reader resolves through here, so one number governs the pass
43
+ * decision, its journal row and `/ctx-status`. Callers that need the fact
44
+ * itself — the DSH route window as disclosed — still read `contextWindow`.
45
+ */
46
+ export function pressureWindowTokens(contextWindow, policy) {
47
+ return contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow > 0
48
+ ? contextWindow
49
+ : policy.assumedContextWindow;
50
+ }
51
+ /**
52
+ * One pass decision. Mirrors MC's scheduler: execute at the threshold, or
53
+ * when the idle clock (last served response, not last prepared request)
54
+ * passes the cache TTL; otherwise defer.
55
+ *
56
+ * Iron rule: the adapter must never leave the main wire unpairable. A
57
+ * stranded `tool_result` 400s every strict-provider request, so any live
58
+ * wire defect forces the execute band before pressure is even considered.
59
+ */
60
+ export function decidePass(args) {
61
+ if ((args.wireDefects ?? 0) > 0)
62
+ return { kind: 'execute', reason: 'wire-repair' };
63
+ if (args.latest === undefined)
64
+ return { kind: 'defer', reason: 'no-usage' };
65
+ if (args.pressure === undefined)
66
+ return { kind: 'defer', reason: 'no-window' };
67
+ if (args.pressure >= args.policy.executeThreshold)
68
+ return { kind: 'execute', reason: 'execute-threshold' };
69
+ if (args.now - args.latest.recordedAt > args.policy.ttlMs) {
70
+ return { kind: 'execute', reason: 'cache-ttl-expired' };
71
+ }
72
+ return { kind: 'defer', reason: 'below-threshold' };
73
+ }
74
+ /**
75
+ * Parse MC's drop-range grammar: "3-5,8,12-15" → unique tag numbers.
76
+ *
77
+ * Ascending by default (the `ctx_reduce` contract: batch order is irrelevant).
78
+ * `ctx_expand` passes `preserveOrder` because its payload is joined in request
79
+ * order (contract D4/Q3), so "9,2" must stay 9 before 2.
80
+ */
81
+ export function parseTagRanges(input, options) {
82
+ const tags = [];
83
+ const seen = new Set();
84
+ const add = (tag) => {
85
+ if (seen.has(tag))
86
+ return;
87
+ seen.add(tag);
88
+ tags.push(tag);
89
+ };
90
+ for (const part of input.split(',')) {
91
+ const token = part.trim();
92
+ if (token.length === 0)
93
+ continue;
94
+ const range = token.match(/^(\d+)-(\d+)$/);
95
+ if (range !== null) {
96
+ const start = Number(range[1]);
97
+ const end = Number(range[2]);
98
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start || end - start > 10_000)
99
+ continue;
100
+ for (let tag = start; tag <= end; tag++)
101
+ add(tag);
102
+ continue;
103
+ }
104
+ if (/^\d+$/.test(token)) {
105
+ const tag = Number(token);
106
+ if (tag >= 1)
107
+ add(tag);
108
+ }
109
+ }
110
+ return options?.preserveOrder === true ? tags : tags.sort((a, b) => a - b);
111
+ }
@@ -0,0 +1,55 @@
1
+ import { buildConfirmationQuestion, classifyProfileShape } from "./directive-detect.js";
2
+ /**
3
+ * Decide what happens to one candidate.
4
+ *
5
+ * Descriptive -> the caller may promote it through the normal path.
6
+ * Directive -> queued, and the caller must NOT promote it.
7
+ *
8
+ * Enqueueing is idempotent per pending candidate, so a retried historian pass
9
+ * cannot produce a second question for the same observation.
10
+ */
11
+ export function gateProfileCandidate(store, input, now) {
12
+ const verdict = classifyProfileShape(input.content);
13
+ if (verdict.shape !== 'directive')
14
+ return { shape: 'descriptive', verdict };
15
+ const confirmation = store.enqueueProfileConfirmation({
16
+ contractCandidateId: input.contractCandidateId,
17
+ question: buildConfirmationQuestion(input.content, verdict),
18
+ content: input.content,
19
+ }, now);
20
+ return { shape: 'directive', verdict, confirmation };
21
+ }
22
+ export function listPendingConfirmations(store) {
23
+ return store.listProfileConfirmations('pending');
24
+ }
25
+ /**
26
+ * Answer a pending question. Returns the decided row, or `undefined` when the
27
+ * id is unknown or was already decided — deciding twice must not resurrect a
28
+ * candidate the human already rejected.
29
+ */
30
+ export function decideConfirmation(store, id, decision, now) {
31
+ const pending = store.listProfileConfirmations('pending').find(row => row.id === id);
32
+ if (pending === undefined)
33
+ return undefined;
34
+ store.decideProfileConfirmation(id, decision, now);
35
+ return { ...pending, state: decision, decidedAt: now };
36
+ }
37
+ /**
38
+ * The one-line summary a session notice carries. Deliberately includes the
39
+ * command surface, because a question with no way to answer it is just noise.
40
+ */
41
+ export function formatConfirmationNotice(rows) {
42
+ if (rows.length === 0)
43
+ return undefined;
44
+ const head = rows.length === 1
45
+ ? '有 1 条画像候选需要你确认(它会在所有会话生效)'
46
+ : `有 ${rows.length} 条画像候选需要你确认(它们会在所有会话生效)`;
47
+ const lines = [head];
48
+ for (const row of rows.slice(0, 3)) {
49
+ lines.push(` #${String(row.id)} ${row.content.replace(/\s+/g, ' ').trim().slice(0, 72)}`);
50
+ }
51
+ if (rows.length > 3)
52
+ lines.push(` …还有 ${String(rows.length - 3)} 条`);
53
+ lines.push(' 用 /ctx-profile list 查看全文,confirm <id> 确认,reject <id> 丢弃。');
54
+ return lines.join('\n');
55
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * WP7 — the `user` domain's REVIEW pass.
3
+ *
4
+ * Candidates (WP5) become profile rows (`user_memories`) that render into m[0]
5
+ * (WP8). Magic Context does this in a daily `review-user-memories` task that
6
+ * needs a session-scoped dreamer; DSH has no such thing, so the pass rides the
7
+ * existing maintenance tick and is throttled by a stored timestamp instead.
8
+ *
9
+ * Four rules carry the design, each of them a divergence or a defect fix:
10
+ *
11
+ * 1. **The candidate TTL does NOT live inside the review.** Upstream runs its
12
+ * 30-day decay inside the review task, so switching the review off — or
13
+ * having no LLM route at all — means candidates are never cleaned and the
14
+ * pool grows without bound. Here expiry runs first, unconditionally, before
15
+ * any of the reasons that can skip the review.
16
+ * 2. **Failure is closed, and it is closed on the WHOLE verdict.** A truncated
17
+ * or unparsable answer promotes nothing and consumes nothing: the candidates
18
+ * stay for the next tick, which is always cheaper than acting on half a
19
+ * judgement.
20
+ * 3. **Consumption is a UNION, never just what the model listed.** A candidate
21
+ * is consumed when the model names it, when its content is what got
22
+ * promoted, or when the model rejects it — and a candidate whose content is
23
+ * already promoted is consumed even though the promotion itself is skipped
24
+ * as a duplicate. A model that forgets `consume_candidate_ids` therefore
25
+ * cannot make the same candidate be re-judged forever.
26
+ * 4. **Candidates are consumed only for promotions that actually landed.**
27
+ * Deleting the inputs of a failed INSERT would lose the observation with
28
+ * nothing to show for it.
29
+ * 5. **A decided pass is never silent.** A skip that means "nothing to do
30
+ * yet" (no-candidates / too-few / too-few-sessions) journals one info row
31
+ * AND stamps the daily throttle, so the pass leaves exactly one
32
+ * profile-review row per interval: a quiet journal can never again be read
33
+ * as "the lane is dead", and a persistent skip cannot spam every tick.
34
+ * Failed attempts (llm-off / llm-failed / invalid-output) stamp nothing,
35
+ * so they stay retried on the next tick.
36
+ */
37
+ import { LlmDeadlineError, withDeadline } from "./llm-deadline.js";
38
+ import { errText } from "./types.js";
39
+ /** Candidates needed before a review is worth an LLM call. */
40
+ export const PROFILE_REVIEW_THRESHOLD = 3;
41
+ /**
42
+ * Distinct SESSIONS the supporting candidates must come from before a profile
43
+ * row may be promoted (2026-10-16 user decision; execution plan §8 C-0).
44
+ *
45
+ * The profile is injected into EVERY session, so the failure mode is one
46
+ * talkative session turning its own quirk into a standing rule for all of them.
47
+ * Upstream MC guards this with cross-session recurrence; the `org` domain has
48
+ * the symmetric gate (`distinct_identities >= 2`), while this pass only counted
49
+ * the pool size — three near-identical observations from ONE session could
50
+ * promote. Counting distinct sessions closes that hole symmetrically.
51
+ */
52
+ export const PROFILE_REVIEW_MIN_SESSIONS = 2;
53
+ /** At most one review per day (MC's cadence, without MC's scheduler). */
54
+ export const PROFILE_REVIEW_INTERVAL_MS = 24 * 60 * 60 * 1000;
55
+ /** MC's candidate TTL, enforced by expiry regardless of the switch. */
56
+ export const PROFILE_CANDIDATE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
57
+ /** Candidates read per review — bounded so one tick cannot become a batch job. */
58
+ export const PROFILE_REVIEW_BATCH = 40;
59
+ /** Output cap for the verdict. */
60
+ export const PROFILE_REVIEW_MAX_TOKENS = 4_096;
61
+ /**
62
+ * Wall-clock budget for the review's single round trip, for the same reason the
63
+ * verify lane has one: the review rides the maintenance tick, and the ticker's
64
+ * interval is installed only after a tick resolves — a stream that never settles
65
+ * would therefore stop the whole maintenance lane for the life of the process.
66
+ */
67
+ export const PROFILE_REVIEW_TIMEOUT_MS = 120_000;
68
+ /** A promoted profile row is a standing rule; longer than this is not one. */
69
+ export const PROFILE_MAX_CONTENT_CHARS = 800;
70
+ /**
71
+ * Adapter-meta key holding when a review pass last reached a DECIDED state —
72
+ * a run, or a nothing-to-do skip (rule 5 above). The throttled timestamp
73
+ * lives here rather than in the contract: it is our scheduling state, not
74
+ * Magic Context's data, and writing it must not touch the shared store.
75
+ */
76
+ export const PROFILE_REVIEW_META_KEY = 'profile_last_review_at';
77
+ export const PROFILE_REVIEW_INSTRUCTION = [
78
+ 'You maintain a USER PROFILE: statements about the user that stay true across projects.',
79
+ 'You are given candidate observations collected from past sessions. Decide which belong in the profile.',
80
+ '',
81
+ 'Return ONLY one JSON object, no prose, no code fences:',
82
+ '{',
83
+ ' "promote": [{ "content": "...", "consume_candidate_ids": [1, 2] }],',
84
+ ' "reject": [3]',
85
+ '}',
86
+ '',
87
+ 'Rules:',
88
+ '- "promote" holds ONE entry per distinct standing fact. Merge near-duplicates into a single entry and put',
89
+ ' EVERY candidate id that fed it into "consume_candidate_ids".',
90
+ '- Treat the user as the subject. Project-specific facts (paths, module names, one-off incidents) do NOT',
91
+ ' belong here: reject them.',
92
+ '- Promoted content must be a standalone statement that reads correctly with no other context.',
93
+ '- "reject" lists candidate ids that will never be promoted.',
94
+ '- Every candidate id you were given must appear either in some "consume_candidate_ids" or in "reject".',
95
+ '- If nothing qualifies, return {"promote":[],"reject":[]}. Never invent candidates.',
96
+ ].join('\n');
97
+ /** Case/whitespace-insensitive form used for duplicate detection. */
98
+ export function normalizeProfileContent(content) {
99
+ return content.toLowerCase().replace(/\s+/g, ' ').trim();
100
+ }
101
+ /**
102
+ * Parse the review answer, fail-closed.
103
+ *
104
+ * The three failure modes the plan names — truncated JSON, no output, invalid
105
+ * JSON — all return `undefined`, and the caller then promotes and consumes
106
+ * nothing. Entries missing usable content are dropped individually (they cannot
107
+ * be promoted either way); a malformed `promote`/`reject` CONTAINER fails the
108
+ * whole verdict, because a partially-read verdict is a partially-applied
109
+ * judgement.
110
+ */
111
+ export function parseProfileReviewOutput(text) {
112
+ const open = text.indexOf('{');
113
+ const close = text.lastIndexOf('}');
114
+ if (open === -1 || close <= open)
115
+ return undefined;
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(text.slice(open, close + 1));
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
124
+ return undefined;
125
+ const record = parsed;
126
+ const rawPromote = record.promote;
127
+ const rawReject = record.reject;
128
+ if (rawPromote !== undefined && !Array.isArray(rawPromote))
129
+ return undefined;
130
+ if (rawReject !== undefined && !Array.isArray(rawReject))
131
+ return undefined;
132
+ const promote = [];
133
+ for (const entry of (rawPromote ?? [])) {
134
+ if (entry === null || typeof entry !== 'object')
135
+ continue;
136
+ const row = entry;
137
+ const content = typeof row.content === 'string' ? row.content.trim() : '';
138
+ if (content === '')
139
+ continue;
140
+ const ids = Array.isArray(row.consume_candidate_ids)
141
+ ? row.consume_candidate_ids.filter((id) => Number.isInteger(id))
142
+ : [];
143
+ promote.push({
144
+ content: content.length > PROFILE_MAX_CONTENT_CHARS ? content.slice(0, PROFILE_MAX_CONTENT_CHARS) : content,
145
+ consumeCandidateIds: ids,
146
+ });
147
+ }
148
+ const reject = Array.isArray(rawReject)
149
+ ? rawReject.filter((id) => Number.isInteger(id))
150
+ : [];
151
+ return { promote, reject };
152
+ }
153
+ /**
154
+ * Turn a verdict into writes and deletions. Pure, so the union rule is testable
155
+ * without a database or a model. See the rule list at the top of the file.
156
+ */
157
+ export function planProfileReview(input) {
158
+ const sent = new Map(input.candidates.map(row => [row.id, row]));
159
+ const byContent = new Map();
160
+ for (const row of input.candidates) {
161
+ const key = normalizeProfileContent(row.content);
162
+ byContent.set(key, [...(byContent.get(key) ?? []), row.id]);
163
+ }
164
+ const already = new Set(input.existing.map(normalizeProfileContent));
165
+ const consumed = new Set();
166
+ const promotions = [];
167
+ for (const entry of input.verdict.promote) {
168
+ const key = normalizeProfileContent(entry.content);
169
+ // Named ids and content matches are BOTH consumed, and both are collected
170
+ // even when the promotion itself is skipped as a duplicate: a candidate
171
+ // already represented in the profile must not be re-judged forever.
172
+ const own = [...new Set([
173
+ ...entry.consumeCandidateIds.filter(id => sent.has(id)),
174
+ ...(byContent.get(key) ?? []),
175
+ ])].sort((left, right) => left - right);
176
+ for (const id of own)
177
+ consumed.add(id);
178
+ if (key === '' || already.has(key))
179
+ continue;
180
+ already.add(key);
181
+ promotions.push({ content: entry.content, consume: own });
182
+ }
183
+ for (const id of input.verdict.reject) {
184
+ if (sent.has(id))
185
+ consumed.add(id);
186
+ }
187
+ return { promotions, consumed: [...consumed].sort((left, right) => left - right) };
188
+ }
189
+ /** One LLM call, collected by block index — the same shape the tick uses. */
190
+ async function collectText(llm, route, sessionId, prompt, budgetMs = PROFILE_REVIEW_TIMEOUT_MS) {
191
+ try {
192
+ return await withDeadline(async (signal) => {
193
+ const byIndex = new Map();
194
+ let usage;
195
+ const chunks = llm.stream({
196
+ provider: route.provider,
197
+ model: route.model,
198
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
199
+ maxTokens: PROFILE_REVIEW_MAX_TOKENS,
200
+ sessionId,
201
+ purpose: 'compaction',
202
+ signal,
203
+ });
204
+ for await (const raw of chunks) {
205
+ const chunk = raw;
206
+ if (chunk.type === 'text-delta' && typeof chunk.index === 'number' && typeof chunk.text === 'string') {
207
+ byIndex.set(chunk.index, (byIndex.get(chunk.index) ?? '') + chunk.text);
208
+ }
209
+ else if (chunk.type === 'usage') {
210
+ const reported = raw.usage;
211
+ const numeric = {};
212
+ for (const [key, value] of Object.entries(reported ?? {})) {
213
+ if (typeof value === 'number')
214
+ numeric[key] = value;
215
+ }
216
+ usage = numeric;
217
+ }
218
+ else if (chunk.type === 'finish') {
219
+ const kind = chunk.reason?.kind;
220
+ if (kind === 'error' || kind === 'aborted') {
221
+ return { ok: false, why: `stream finished (${kind})${chunk.reason?.failure?.message === undefined ? '' : `: ${chunk.reason.failure.message}`}` };
222
+ }
223
+ }
224
+ }
225
+ const text = [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, value]) => value).join('');
226
+ if (text.trim().length === 0)
227
+ return { ok: false, why: 'stream produced no text deltas' };
228
+ return { ok: true, text, ...usage === undefined ? {} : { usage } };
229
+ }, budgetMs);
230
+ }
231
+ catch (error) {
232
+ return {
233
+ ok: false,
234
+ why: error instanceof LlmDeadlineError ? `deadline ${error.message}` : errText(error),
235
+ };
236
+ }
237
+ }
238
+ function renderCandidate(row, nowMs) {
239
+ const ageDays = Math.max(0, Math.round((nowMs - row.createdAt) / 86_400_000));
240
+ const text = row.content.replace(/\s+/g, ' ');
241
+ return `- id ${String(row.id)} (age ${String(ageDays)}d): ${text}`;
242
+ }
243
+ /**
244
+ * Run one review. Never throws: every failure is journaled and the pool is left
245
+ * exactly as it was, so the next tick sees the same candidates.
246
+ */
247
+ export async function runProfileReview(deps) {
248
+ const now = (deps.now ?? Date.now)();
249
+ const writer = deps.writer;
250
+ const empty = { expired: 0, ran: false, promoted: 0, consumed: 0 };
251
+ if (writer === undefined || writer.status.ok !== true) {
252
+ return { ...empty, reason: 'writer-unavailable' };
253
+ }
254
+ // (1) TTL FIRST, and unconditionally. Upstream's decay lives inside its review
255
+ // task, so turning the review off (or having no route) leaks candidates
256
+ // forever; here the pool is bounded no matter what happens below.
257
+ const expired = writer.expireCandidates(now - PROFILE_CANDIDATE_TTL_MS);
258
+ if (deps.llm === undefined || deps.route === undefined) {
259
+ deps.journal.info('maintenance', `profile review skipped (no llm route); expired ${String(expired)}`, {
260
+ expired,
261
+ reason: 'llm-off',
262
+ });
263
+ return { ...empty, expired, reason: 'llm-off' };
264
+ }
265
+ if (deps.lastReviewAt !== undefined && now - deps.lastReviewAt < PROFILE_REVIEW_INTERVAL_MS) {
266
+ return { ...empty, expired, reason: 'throttled' };
267
+ }
268
+ const threshold = Math.max(1, deps.threshold ?? PROFILE_REVIEW_THRESHOLD);
269
+ const batch = Math.max(threshold, deps.batch ?? PROFILE_REVIEW_BATCH);
270
+ const candidates = writer.listCandidates(batch);
271
+ if (candidates.length === 0) {
272
+ deps.journal.info('maintenance', `profile review skipped (no candidates); expired ${String(expired)}`, {
273
+ expired,
274
+ reason: 'no-candidates',
275
+ });
276
+ return { ...empty, expired, reason: 'no-candidates', attempted: true };
277
+ }
278
+ if (candidates.length < threshold) {
279
+ deps.journal.info('maintenance', `profile review skipped (${String(candidates.length)} candidate(s) below threshold ${String(threshold)}); expired ${String(expired)}`, { expired, candidates: candidates.length, threshold, reason: 'too-few' });
280
+ return { ...empty, expired, reason: 'too-few', attempted: true };
281
+ }
282
+ // Cross-session gate (see PROFILE_REVIEW_MIN_SESSIONS). Checked before the LLM
283
+ // call: a batch contributed by a single session cannot promote anything, so
284
+ // paying for a judgement on it would only burn tokens and produce a verdict
285
+ // that must be discarded.
286
+ const minSessions = Math.max(1, deps.minSessions ?? PROFILE_REVIEW_MIN_SESSIONS);
287
+ const sessions = new Set(candidates.map(row => row.sessionId));
288
+ if (sessions.size < minSessions) {
289
+ deps.journal.info('maintenance', `profile review skipped (candidates span ${String(sessions.size)} session(s), need ${String(minSessions)}); expired ${String(expired)}`, { candidates: candidates.length, sessions: sessions.size, reason: 'too-few-sessions' });
290
+ return { ...empty, expired, reason: 'too-few-sessions', attempted: true };
291
+ }
292
+ const prompt = PROFILE_REVIEW_INSTRUCTION + '\n\nCandidate observations:\n'
293
+ + candidates.map(row => renderCandidate(row, now)).join('\n');
294
+ const call = await collectText(deps.llm, deps.route, deps.sessionId, prompt, deps.reviewTimeoutMs);
295
+ if (!call.ok) {
296
+ deps.journal.warn('maintenance', `profile review call failed (retried next tick): ${call.why}`, {
297
+ candidates: candidates.length,
298
+ why: call.why,
299
+ route: `${deps.route.provider}/${deps.route.model}`,
300
+ });
301
+ return { ...empty, expired, reason: 'llm-failed' };
302
+ }
303
+ // (2) Fail closed on the whole verdict: nothing promoted, nothing consumed.
304
+ const verdict = parseProfileReviewOutput(call.text);
305
+ if (verdict === undefined) {
306
+ deps.journal.warn('maintenance', 'profile review answer rejected (invalid JSON); candidates kept', {
307
+ candidates: candidates.length,
308
+ outputChars: call.text.length,
309
+ outputTail: call.text.slice(-400),
310
+ });
311
+ return { ...empty, expired, reason: 'invalid-output' };
312
+ }
313
+ const existing = writer.listActiveProfiles().map(row => row.content);
314
+ const plan = planProfileReview({ candidates, existing, verdict });
315
+ const provenance = JSON.stringify({
316
+ sessions: [...new Set(candidates.map(row => row.sessionId))],
317
+ candidateIds: candidates.map(row => row.id),
318
+ });
319
+ // Per-promotion cross-session gate: the batch check above only proves that TWO
320
+ // sessions contributed SOMETHING; a promotion must itself rest on more than one
321
+ // session's observations. One that does not is held back here — and its
322
+ // candidates are deliberately NOT consumed, so it can be judged again once
323
+ // another session corroborates it.
324
+ const byId = new Map(candidates.map(row => [row.id, row]));
325
+ const kept = [];
326
+ const heldBack = [];
327
+ for (const promotion of plan.promotions) {
328
+ const spanned = new Set(promotion.consume
329
+ .map(id => byId.get(id)?.sessionId)
330
+ .filter((sid) => sid !== undefined));
331
+ if (spanned.size < minSessions) {
332
+ heldBack.push(...promotion.consume);
333
+ continue;
334
+ }
335
+ kept.push(promotion);
336
+ }
337
+ if (heldBack.length > 0) {
338
+ deps.journal.info('maintenance', `profile review held back ${String(heldBack.length)} candidate(s): a promotion needs observations from ${String(minSessions)} session(s)`, { heldBack: [...new Set(heldBack)].sort((left, right) => left - right), reason: 'too-few-sessions' });
339
+ }
340
+ const promotedIds = [];
341
+ const landed = [];
342
+ for (const promotion of kept) {
343
+ const id = writer.promoteProfile({
344
+ content: promotion.content,
345
+ candidateIds: promotion.consume,
346
+ promotedAt: now,
347
+ provenance,
348
+ });
349
+ // (4) Only a promotion that LANDED may consume its inputs.
350
+ if (id !== undefined) {
351
+ promotedIds.push(id);
352
+ landed.push(...promotion.consume);
353
+ }
354
+ }
355
+ // Rejections are consumption by definition — the model said these never
356
+ // become profile rows — so they are dropped whether or not anything promoted.
357
+ const rejected = verdict.reject.filter(id => candidates.some(row => row.id === id));
358
+ const toDelete = [...new Set([...landed, ...rejected])];
359
+ const consumed = toDelete.length === 0 ? 0 : writer.deleteCandidates(toDelete);
360
+ const usage = call.usage !== undefined
361
+ ? { inputTokens: call.usage.inputTokens ?? 0, outputTokens: call.usage.outputTokens ?? 0 }
362
+ : undefined;
363
+ deps.journal.info('maintenance', `profile review: ${String(promotedIds.length)} promoted, ${String(consumed)} candidate(s) consumed`
364
+ + `${expired > 0 ? `, ${String(expired)} expired` : ''}`, {
365
+ ran: true,
366
+ expired,
367
+ candidates: candidates.length,
368
+ promoted: promotedIds.length,
369
+ promotedIds,
370
+ consumed,
371
+ rejected: rejected.length,
372
+ ...usage === undefined ? {} : { usage },
373
+ });
374
+ return { expired, ran: true, attempted: true, promoted: promotedIds.length, consumed };
375
+ }