aiden-runtime 4.1.0 → 4.1.2

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 (61) hide show
  1. package/README.md +89 -33
  2. package/dist/cli/v4/aidenCLI.js +162 -11
  3. package/dist/cli/v4/callbacks.js +5 -2
  4. package/dist/cli/v4/chatSession.js +525 -15
  5. package/dist/cli/v4/commands/auth.js +6 -3
  6. package/dist/cli/v4/commands/help.js +4 -0
  7. package/dist/cli/v4/commands/index.js +10 -1
  8. package/dist/cli/v4/commands/reloadSoul.js +37 -0
  9. package/dist/cli/v4/commands/update.js +102 -0
  10. package/dist/cli/v4/defaultSoul.js +68 -2
  11. package/dist/cli/v4/display.js +28 -10
  12. package/dist/cli/v4/doctor.js +173 -1
  13. package/dist/cli/v4/doctorLiveness.js +384 -0
  14. package/dist/cli/v4/promotionPrompt.js +202 -0
  15. package/dist/cli/v4/providerBootSelector.js +144 -0
  16. package/dist/cli/v4/sessionSummaryGate.js +66 -0
  17. package/dist/cli/v4/toolPreview.js +139 -0
  18. package/dist/core/v4/aidenAgent.js +91 -29
  19. package/dist/core/v4/capabilities.js +89 -0
  20. package/dist/core/v4/contextCompressor.js +25 -8
  21. package/dist/core/v4/distillationIndex.js +167 -0
  22. package/dist/core/v4/distillationStore.js +98 -0
  23. package/dist/core/v4/logger/logger.js +40 -9
  24. package/dist/core/v4/promotionCandidates.js +234 -0
  25. package/dist/core/v4/promptBuilder.js +145 -1
  26. package/dist/core/v4/sessionDistiller.js +405 -0
  27. package/dist/core/v4/skillMining/extractorPrompt.js +28 -21
  28. package/dist/core/v4/skillMining/proposalBuilder.js +3 -2
  29. package/dist/core/v4/skillMining/skillMiner.js +43 -6
  30. package/dist/core/v4/skillOutcomeTracker.js +323 -0
  31. package/dist/core/v4/subsystemHealth.js +143 -0
  32. package/dist/core/v4/update/executeInstall.js +233 -0
  33. package/dist/core/version.js +1 -1
  34. package/dist/moat/dangerousPatterns.js +1 -1
  35. package/dist/moat/memoryGuard.js +111 -0
  36. package/dist/moat/skillTeacher.js +14 -5
  37. package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
  38. package/dist/providers/v4/codexResponsesAdapter.js +7 -2
  39. package/dist/providers/v4/errors.js +67 -1
  40. package/dist/providers/v4/modelDefaults.js +65 -0
  41. package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
  42. package/dist/providers/v4/registry.js +9 -2
  43. package/dist/providers/v4/runtimeResolver.js +6 -0
  44. package/dist/tools/v4/index.js +57 -1
  45. package/dist/tools/v4/memory/memoryRemove.js +57 -2
  46. package/dist/tools/v4/memory/sessionSummary.js +151 -0
  47. package/dist/tools/v4/sessions/recallSession.js +163 -0
  48. package/dist/tools/v4/sessions/sessionSearch.js +5 -1
  49. package/dist/tools/v4/subagent/subagentFanout.js +24 -0
  50. package/dist/tools/v4/system/_psHelpers.js +55 -0
  51. package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
  52. package/dist/tools/v4/system/appClose.js +79 -0
  53. package/dist/tools/v4/system/appLaunch.js +92 -0
  54. package/dist/tools/v4/system/clipboardRead.js +54 -0
  55. package/dist/tools/v4/system/clipboardWrite.js +84 -0
  56. package/dist/tools/v4/system/mediaKey.js +78 -0
  57. package/dist/tools/v4/system/osProcessList.js +99 -0
  58. package/dist/tools/v4/system/screenshot.js +106 -0
  59. package/dist/tools/v4/system/volumeSet.js +157 -0
  60. package/package.json +4 -1
  61. package/skills/system_control.md +135 -69
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/doctorLiveness.ts — Phase v4.1.1-oauth-fix Phase 4.
10
+ *
11
+ * `aiden doctor --providers` deep-mode helper. Pings every configured /
12
+ * authed provider with a minimal request, reports green / red / skipped
13
+ * + per-provider latency + verbatim upstream error message.
14
+ *
15
+ * Why a separate file:
16
+ * - Default doctor (`aiden doctor` with no flag) stays unchanged and
17
+ * fast — config-shape checks only.
18
+ * - `--providers` is opt-in. When the user types it we extend the
19
+ * report with one liveness row per probe, then render a summary
20
+ * line at the bottom.
21
+ * - Tool-catalog validation is deliberately OUT of scope. The
22
+ * probe ships ONE hardcoded no-op tool (`probe_noop`) so the
23
+ * Codex backend accepts the request (it rejects empty `tools`),
24
+ * while user-registered tool schemas stay un-validated here. The
25
+ * eval-harness / registration-time schema validator (v4.1.1
26
+ * main) is the right home for that concern.
27
+ *
28
+ * Trust artifact:
29
+ * - On failure we surface `err.message` VERBATIM (truncated to 200
30
+ * chars). Phase 3 (`providers/v4/errors.ts`) already composes the
31
+ * upstream response body into ProviderError.message — so a 400
32
+ * prints the actual OpenAI reason, not a generic "provider failed."
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.pickProbeModel = pickProbeModel;
36
+ exports.enumerateConfiguredProviders = enumerateConfiguredProviders;
37
+ exports.checkProviderLiveness = checkProviderLiveness;
38
+ exports.runProviderLiveness = runProviderLiveness;
39
+ exports.renderProviderLivenessSection = renderProviderLivenessSection;
40
+ const registry_1 = require("../../providers/v4/registry");
41
+ const runtimeResolver_1 = require("../../providers/v4/runtimeResolver");
42
+ const credentialResolver_1 = require("../../providers/v4/credentialResolver");
43
+ const tokenStore_1 = require("../../core/v4/auth/tokenStore");
44
+ const DEFAULT_LIVENESS_TIMEOUT_MS = 8000;
45
+ const PROBE_MAX_TOKENS = 4;
46
+ const ERROR_TRUNCATE_CHARS = 200;
47
+ const OLLAMA_PROBE_TIMEOUT_MS = 1500;
48
+ const OLLAMA_HEALTH_URL = 'http://127.0.0.1:11434/api/tags';
49
+ // ── Helpers ─────────────────────────────────────────────────────────────
50
+ /**
51
+ * Truncate a long error string for display. The full error remains on
52
+ * the in-memory `LivenessResult.error` for programmatic consumers /
53
+ * test assertions.
54
+ */
55
+ function truncate(s, max = ERROR_TRUNCATE_CHARS) {
56
+ if (s.length <= max)
57
+ return s;
58
+ return `${s.slice(0, max - 1)}…`;
59
+ }
60
+ /**
61
+ * Phase v4.1.2-slice5: pick a probe-safe model id from the registry.
62
+ *
63
+ * Some providers list model slugs that only work for enterprise / CLI
64
+ * accounts. ChatGPT Plus is the canonical case: the registry's
65
+ * `modelIds[0]` is `gpt-5.1-codex-max`, which is rejected by the
66
+ * subscription-account Codex backend with
67
+ * `"The 'gpt-5.1-codex-max' model is not supported when using Codex
68
+ * with a ChatGPT account."` — even though real REPL chat on the same
69
+ * account works because the user has selected a non-Codex slug
70
+ * (`gpt-5.5`).
71
+ *
72
+ * Heuristic: skip any slug containing `-codex` (covers `-codex-max`,
73
+ * `-codex-mini`, plain `-codex` suffix variants). Falls back to
74
+ * `modelIds[0]` if every slug is Codex-flavoured. No provider id
75
+ * special-casing — the heuristic is shape-based so future-similar
76
+ * providers benefit too.
77
+ */
78
+ function pickProbeModel(entry) {
79
+ const safe = entry.modelIds.find((m) => !m.includes('-codex'));
80
+ return safe ?? entry.modelIds[0] ?? '';
81
+ }
82
+ /**
83
+ * Wrap a promise with a hard timeout. Resolves to the inner result on
84
+ * success, throws a clearly-labelled `Error` on timeout. Cleans up the
85
+ * timer either way.
86
+ */
87
+ async function withTimeout(p, ms, label) {
88
+ let timer;
89
+ try {
90
+ return await Promise.race([
91
+ p,
92
+ new Promise((_, reject) => {
93
+ timer = setTimeout(() => reject(new Error(`${label}: timeout after ${ms}ms`)), ms);
94
+ }),
95
+ ]);
96
+ }
97
+ finally {
98
+ if (timer)
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ /**
103
+ * Decide whether a provider counts as "configured" for liveness purposes.
104
+ * Three paths:
105
+ * 1. API key in env (`apiKeyEnvVar` set and the env var is non-empty).
106
+ * 2. OAuth (entry.oauth present and a non-expired token sits in the
107
+ * tokenStore at <paths.root>/auth/<providerId>.json).
108
+ * 3. Local providers (ollama) — probed via a quick HTTP health check.
109
+ *
110
+ * Anything else is `configured: false` and gets a `skip_reason`.
111
+ */
112
+ async function enumerateConfiguredProviders(opts) {
113
+ const env = opts.env ?? process.env;
114
+ const fetchImpl = opts.fetchImpl ?? fetch;
115
+ const out = [];
116
+ for (const entry of Object.values(registry_1.PROVIDER_REGISTRY)) {
117
+ // Every provider needs at least one model to probe against.
118
+ const model = pickProbeModel(entry);
119
+ if (!model) {
120
+ out.push({
121
+ entry,
122
+ model: '',
123
+ configured: false,
124
+ reason: 'no models declared in registry',
125
+ });
126
+ continue;
127
+ }
128
+ // 1. API-key providers.
129
+ if (entry.apiKeyEnvVar) {
130
+ const value = env[entry.apiKeyEnvVar];
131
+ if (value && value.length > 0) {
132
+ out.push({ entry, model, configured: true });
133
+ }
134
+ else {
135
+ out.push({
136
+ entry,
137
+ model,
138
+ configured: false,
139
+ reason: `env ${entry.apiKeyEnvVar} not set`,
140
+ });
141
+ }
142
+ continue;
143
+ }
144
+ // 2. OAuth providers — check tokenStore.
145
+ if (entry.oauth) {
146
+ try {
147
+ const tokens = await (0, tokenStore_1.loadTokens)(opts.paths, entry.oauth.providerId);
148
+ if (tokens && tokens.accessToken) {
149
+ if ((0, tokenStore_1.isExpired)(tokens, tokenStore_1.PREFLIGHT_REFRESH_WINDOW_MS)) {
150
+ out.push({
151
+ entry,
152
+ model,
153
+ configured: false,
154
+ reason: 'OAuth token expired — run `/auth refresh` or `/auth login`',
155
+ });
156
+ }
157
+ else {
158
+ out.push({ entry, model, configured: true });
159
+ }
160
+ }
161
+ else {
162
+ out.push({
163
+ entry,
164
+ model,
165
+ configured: false,
166
+ reason: 'no OAuth token — run `/auth login`',
167
+ });
168
+ }
169
+ }
170
+ catch (err) {
171
+ out.push({
172
+ entry,
173
+ model,
174
+ configured: false,
175
+ reason: `tokenStore read failed: ${err.message}`,
176
+ });
177
+ }
178
+ continue;
179
+ }
180
+ // 3. Local / no-credential providers (ollama). Configured = the
181
+ // local daemon answers a health probe.
182
+ const controller = new AbortController();
183
+ const timer = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS);
184
+ try {
185
+ const res = await fetchImpl(OLLAMA_HEALTH_URL, { signal: controller.signal });
186
+ if (res.ok) {
187
+ out.push({ entry, model, configured: true });
188
+ }
189
+ else {
190
+ out.push({
191
+ entry,
192
+ model,
193
+ configured: false,
194
+ reason: `local daemon HTTP ${res.status}`,
195
+ });
196
+ }
197
+ }
198
+ catch (err) {
199
+ out.push({
200
+ entry,
201
+ model,
202
+ configured: false,
203
+ reason: `not running on ${OLLAMA_HEALTH_URL}`,
204
+ });
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Probe one provider/model pair via a minimal `adapter.call()`. Returns
214
+ * a structured result; never throws.
215
+ *
216
+ * On failure, `result.error` is `err.message` verbatim truncated to
217
+ * 200 chars. Phase v4.1.1-oauth-fix Phase 3 made `ProviderError.message`
218
+ * carry the upstream response body, so a 400 surfaces the actual
219
+ * reason (e.g. "Invalid schema for function 'subagent_fanout': …").
220
+ */
221
+ async function checkProviderLiveness(provider, model, adapter, opts) {
222
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS;
223
+ const start = Date.now();
224
+ // Liveness probes "is this provider reachable + authenticated?".
225
+ // Tool-catalog validation is a separate concern (eval harness,
226
+ // v4.1.1 main).
227
+ //
228
+ // Phase v4.1.2-slice5: the probe used to send `messages: [user]`
229
+ // only, with `tools: []`. That body 400s against the Codex backend
230
+ // for two reasons:
231
+ // 1. No system message → empty `instructions` field in the wire
232
+ // body. Codex rejects requests without `instructions` (same
233
+ // root cause as the eval-runner fix in 6535d531).
234
+ // 2. Empty tools array → the codex adapter omits `tools`,
235
+ // `tool_choice`, `parallel_tool_calls` from the wire body
236
+ // entirely. The Codex backend treats this as malformed.
237
+ //
238
+ // Fix: add a minimal one-line system message (collapses into
239
+ // `instructions`) and one hand-crafted no-op tool. The probe tool
240
+ // is hardcoded with a conservative JSON Schema
241
+ // (`additionalProperties: false`) so strict validators accept it.
242
+ // The "one bad tool schema false-reds everyone" concern from the
243
+ // pre-slice5 comment applied to USER tools; this tool is internal.
244
+ const input = {
245
+ messages: [
246
+ {
247
+ role: 'system',
248
+ content: 'You are an availability probe. Respond with a single word.',
249
+ },
250
+ { role: 'user', content: 'ping' },
251
+ ],
252
+ tools: [
253
+ {
254
+ name: 'probe_noop',
255
+ description: 'Probe placeholder. Do not call — the probe ignores any tool calls.',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {},
259
+ },
260
+ },
261
+ ],
262
+ maxTokens: PROBE_MAX_TOKENS,
263
+ };
264
+ try {
265
+ await withTimeout(adapter.call(input), timeoutMs, `liveness ${provider}`);
266
+ return {
267
+ provider,
268
+ model,
269
+ status: 'green',
270
+ latency_ms: Date.now() - start,
271
+ };
272
+ }
273
+ catch (err) {
274
+ const raw = err instanceof Error ? err.message : String(err);
275
+ return {
276
+ provider,
277
+ model,
278
+ status: 'red',
279
+ latency_ms: Date.now() - start,
280
+ error: truncate(raw),
281
+ };
282
+ }
283
+ }
284
+ /**
285
+ * Run liveness probes against every provider returned by
286
+ * `enumerateConfiguredProviders`. Unconfigured providers come back as
287
+ * `status: 'skipped'` without any network traffic.
288
+ *
289
+ * Returns `{ results, summary }`. Summary tallies + a wall-clock total
290
+ * so the UI can print "X green · Y red · Z skipped · NNNms total".
291
+ */
292
+ async function runProviderLiveness(opts) {
293
+ const start = Date.now();
294
+ const parallel = opts.parallel ?? true;
295
+ const resolver = opts.resolverImpl ?? new runtimeResolver_1.RuntimeResolver(new credentialResolver_1.CredentialResolver(opts.paths.authJson));
296
+ const configured = await enumerateConfiguredProviders({
297
+ paths: opts.paths,
298
+ env: opts.env,
299
+ fetchImpl: opts.fetchImpl,
300
+ });
301
+ const probe = async (c) => {
302
+ if (!c.configured) {
303
+ return {
304
+ provider: c.entry.id,
305
+ status: 'skipped',
306
+ latency_ms: 0,
307
+ skip_reason: c.reason ?? 'not configured',
308
+ };
309
+ }
310
+ try {
311
+ const adapter = await resolver.resolve({
312
+ providerId: c.entry.id,
313
+ modelId: c.model,
314
+ paths: opts.paths,
315
+ });
316
+ return await checkProviderLiveness(c.entry.id, c.model, adapter, { timeoutMs: opts.timeoutMs });
317
+ }
318
+ catch (err) {
319
+ // Resolve failure (missing credential, unknown model, etc.).
320
+ // Same surface treatment as a probe failure.
321
+ const raw = err instanceof Error ? err.message : String(err);
322
+ return {
323
+ provider: c.entry.id,
324
+ model: c.model,
325
+ status: 'red',
326
+ latency_ms: 0,
327
+ error: truncate(raw),
328
+ };
329
+ }
330
+ };
331
+ const results = parallel
332
+ ? await Promise.all(configured.map(probe))
333
+ : await runSequential(configured.map((c) => () => probe(c)));
334
+ const summary = {
335
+ green: results.filter((r) => r.status === 'green').length,
336
+ red: results.filter((r) => r.status === 'red').length,
337
+ skipped: results.filter((r) => r.status === 'skipped').length,
338
+ total_ms: Date.now() - start,
339
+ };
340
+ return { results, summary };
341
+ }
342
+ async function runSequential(thunks) {
343
+ const out = [];
344
+ for (const t of thunks)
345
+ out.push(await t());
346
+ return out;
347
+ }
348
+ /**
349
+ * Render the liveness section as plain-text rows. The doctor command
350
+ * prints this BELOW the standard health box so the default `aiden doctor`
351
+ * output stays byte-identical.
352
+ *
353
+ * Visual style matches the existing doctor rows (✓ / ✗ / -) but lays
354
+ * out as a tabular block rather than a box — the rows can be long
355
+ * (upstream error bodies) and forcing them into the 100-col box would
356
+ * truncate the diagnostic that's the whole point of the feature.
357
+ */
358
+ function renderProviderLivenessSection(results, summary) {
359
+ const nameWidth = Math.max(8, ...results.map((r) => r.provider.length));
360
+ const lines = [];
361
+ lines.push('');
362
+ lines.push(' Provider liveness (deep check)');
363
+ lines.push(` ${'─'.repeat(60)}`);
364
+ for (const r of results) {
365
+ const icon = r.status === 'green' ? '✓'
366
+ : r.status === 'red' ? '✗'
367
+ : '-';
368
+ const name = r.provider.padEnd(nameWidth);
369
+ const status = r.status === 'green' ? 'green'.padEnd(8)
370
+ : r.status === 'red' ? 'red '.padEnd(8)
371
+ : 'skip '.padEnd(8);
372
+ const latency = r.latency_ms > 0
373
+ ? `${r.latency_ms}ms`.padEnd(8)
374
+ : ''.padEnd(8);
375
+ const tail = r.status === 'green' ? (r.model ?? '')
376
+ : r.status === 'red' ? (r.error ?? 'unknown error')
377
+ : (r.skip_reason ?? 'not configured');
378
+ lines.push(` ${icon} ${name} ${status}${latency}${tail}`);
379
+ }
380
+ lines.push(` ${'─'.repeat(60)}`);
381
+ lines.push(` ${summary.green} green · ${summary.red} red · ${summary.skipped} skipped · ${summary.total_ms}ms total`);
382
+ lines.push('');
383
+ return lines.join('\n');
384
+ }
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/promotionPrompt.ts — Phase v4.1.2-memory-D.
10
+ *
11
+ * REPL-side glue for the durable-facts promotion flow:
12
+ * - `parsePromotionInput(raw, count)` — pure: parse user reply into
13
+ * a 0-indexed array of approved
14
+ * candidate indices.
15
+ * - `formatCandidateList(candidates)` — pure: render the prompt body
16
+ * the user sees.
17
+ * - `promptForApproval(api, ...)` — drives the prompt loop;
18
+ * re-prompts ONCE on garbage,
19
+ * then defaults to skip.
20
+ * - `writeApprovedDurableFacts(...)` — append approved candidates
21
+ * to MEMORY.md `## Durable facts`
22
+ * via MemoryGuard.replaceSection.
23
+ *
24
+ * Input grammar (per Phase D's Q3):
25
+ * - "all" → every shown candidate
26
+ * - "none" / "skip" / "" → none
27
+ * - "1,3" → 0-indexed 0 and 2
28
+ * - "1-3" → 0-indexed 0, 1, 2 (inclusive range)
29
+ * - "1, 3-5" → mixed; whitespace tolerated
30
+ * - Anything unparseable → re-prompt once, then default skip
31
+ *
32
+ * The function intentionally keeps the parser pure so unit tests
33
+ * don't have to drive a prompt API. The prompt-loop function wires
34
+ * the parser to the existing `ChatPromptApi.readLine`.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.parsePromotionInput = parsePromotionInput;
38
+ exports.formatCandidateList = formatCandidateList;
39
+ exports.promptForApproval = promptForApproval;
40
+ exports.buildDurableFactsBody = buildDurableFactsBody;
41
+ exports.readExistingDurableFactsBody = readExistingDurableFactsBody;
42
+ exports.writeApprovedDurableFacts = writeApprovedDurableFacts;
43
+ // ── Parser ────────────────────────────────────────────────────────────────
44
+ /**
45
+ * Parse a user reply into the set of approved candidate indices
46
+ * (0-indexed). Returns `null` to signal "unparseable input — re-prompt
47
+ * once" so callers can distinguish "explicit skip" (empty array) from
48
+ * "garbage typed".
49
+ *
50
+ * Pure, deterministic; safe for unit tests.
51
+ */
52
+ function parsePromotionInput(raw, count) {
53
+ const trimmed = raw.trim().toLowerCase();
54
+ if (trimmed === '' || trimmed === 'none' || trimmed === 'skip')
55
+ return [];
56
+ if (trimmed === 'all') {
57
+ return Array.from({ length: count }, (_, i) => i);
58
+ }
59
+ const out = new Set();
60
+ let sawAnyValid = false;
61
+ // Tolerate "1, 3-5 ,7" with mixed whitespace.
62
+ for (const token of trimmed.split(',')) {
63
+ const piece = token.trim();
64
+ if (!piece)
65
+ continue;
66
+ const range = piece.match(/^(\d+)\s*-\s*(\d+)$/);
67
+ if (range) {
68
+ const start = Number.parseInt(range[1], 10);
69
+ const end = Number.parseInt(range[2], 10);
70
+ if (!Number.isFinite(start) || !Number.isFinite(end))
71
+ continue;
72
+ const [lo, hi] = start <= end ? [start, end] : [end, start];
73
+ for (let n = lo; n <= hi; n += 1) {
74
+ if (n >= 1 && n <= count) {
75
+ out.add(n - 1);
76
+ sawAnyValid = true;
77
+ }
78
+ }
79
+ continue;
80
+ }
81
+ const single = piece.match(/^\d+$/);
82
+ if (single) {
83
+ const n = Number.parseInt(piece, 10);
84
+ if (n >= 1 && n <= count) {
85
+ out.add(n - 1);
86
+ sawAnyValid = true;
87
+ }
88
+ continue;
89
+ }
90
+ // Non-numeric token alongside others — treat the WHOLE input as
91
+ // unparseable so the user gets one re-prompt instead of a silent
92
+ // partial selection.
93
+ return null;
94
+ }
95
+ if (!sawAnyValid)
96
+ return []; // numbers given but all out of range
97
+ return [...out].sort((a, b) => a - b);
98
+ }
99
+ // ── Renderer ──────────────────────────────────────────────────────────────
100
+ /**
101
+ * Build the text the user sees. Pure — caller writes this to display.
102
+ */
103
+ function formatCandidateList(candidates) {
104
+ const lines = [];
105
+ lines.push(`${candidates.length} thing${candidates.length === 1 ? '' : 's'} worth remembering this session. Promote which?`);
106
+ lines.push('');
107
+ for (let i = 0; i < candidates.length; i += 1) {
108
+ const c = candidates[i];
109
+ const sourceTag = c.source === 'explicit' ? '[user said]'
110
+ : c.source === 'decision' ? '[decision]'
111
+ : '[open item]';
112
+ lines.push(` [${i + 1}] ${sourceTag} ${c.text}`);
113
+ }
114
+ lines.push('');
115
+ lines.push('Reply: numbers to approve (e.g. "1,3" or "1-3"), "all", or skip.');
116
+ return lines.join('\n');
117
+ }
118
+ /**
119
+ * Drive the approval prompt. Renders the candidate list, reads ONE
120
+ * line, parses, returns approved Candidate[]. On unparseable input
121
+ * re-prompts ONCE; second failure defaults to skip with a dim line
122
+ * explaining why nothing was promoted.
123
+ *
124
+ * No mid-session state leakage — purely a session-end interaction.
125
+ */
126
+ async function promptForApproval(api, display, candidates) {
127
+ if (candidates.length === 0)
128
+ return [];
129
+ display.write('\n' + formatCandidateList(candidates) + '\n');
130
+ for (let attempt = 0; attempt < 2; attempt += 1) {
131
+ const raw = await api.readLine('Promote > ');
132
+ const parsed = parsePromotionInput(raw, candidates.length);
133
+ if (parsed !== null) {
134
+ if (parsed.length === 0) {
135
+ display.dim('Nothing promoted to durable facts.');
136
+ return [];
137
+ }
138
+ return parsed.map((i) => candidates[i]);
139
+ }
140
+ if (attempt === 0) {
141
+ display.warn('Could not parse input. Use numbers ("1,3"), ranges ("1-3"), "all", or "skip".');
142
+ }
143
+ }
144
+ display.dim('Skipped: input still unparseable. Nothing promoted to durable facts.');
145
+ return [];
146
+ }
147
+ // ── Persistence ───────────────────────────────────────────────────────────
148
+ const DURABLE_FACTS_HEADER = '## Durable facts';
149
+ /**
150
+ * Render the section body for `## Durable facts` by combining existing
151
+ * entries with newly-approved candidates. Newest at the BOTTOM so
152
+ * read order reflects when each fact landed — matches how users scan
153
+ * MEMORY.md.
154
+ *
155
+ * Pure — caller passes existing body (extracted via the same regex
156
+ * pattern MemoryGuard uses in replaceSection).
157
+ */
158
+ function buildDurableFactsBody(existingBody, approved) {
159
+ const existingLines = existingBody
160
+ .split('\n')
161
+ .map((l) => l.trim())
162
+ .filter((l) => l.length > 0);
163
+ const newLines = approved.map((c) => `- ${c.text}`);
164
+ return [...existingLines, ...newLines].join('\n');
165
+ }
166
+ /**
167
+ * Read the current `## Durable facts` body from MEMORY.md (returns
168
+ * empty string when the section doesn't yet exist). Mirrors the
169
+ * regex pattern MemoryGuard.replaceSection uses.
170
+ */
171
+ async function readExistingDurableFactsBody(memoryManager) {
172
+ const snap = await memoryManager.loadSnapshot();
173
+ const md = snap.memoryMd ?? '';
174
+ const headerEscaped = DURABLE_FACTS_HEADER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
175
+ const sectionRe = new RegExp(`${headerEscaped}[^\\n]*\\n([\\s\\S]*?)(?=\\n## |$)`);
176
+ const m = md.match(sectionRe);
177
+ return m ? (m[1] ?? '').trim() : '';
178
+ }
179
+ /**
180
+ * Persist the approved candidates. Reads existing body (so a second
181
+ * session-end appends rather than overwrites), folds in new lines,
182
+ * and writes via MemoryGuard.replaceSection — which handles
183
+ * verify-on-disk + section auto-creation.
184
+ *
185
+ * Returns the GuardedResult so the caller can dim-log success or
186
+ * warn on a failed verify.
187
+ */
188
+ async function writeApprovedDurableFacts(memoryManager, memoryGuard, approved) {
189
+ if (approved.length === 0) {
190
+ return { ok: true, verified: true, entryCount: 0 };
191
+ }
192
+ const existingBody = await readExistingDurableFactsBody(memoryManager);
193
+ const newBody = buildDurableFactsBody(existingBody, approved);
194
+ const entryCount = newBody.split('\n').filter((l) => l.trim().length > 0).length;
195
+ const result = await memoryGuard.replaceSection('memory', DURABLE_FACTS_HEADER, newBody);
196
+ return {
197
+ ok: result.ok,
198
+ verified: result.verified,
199
+ reason: result.reason,
200
+ entryCount,
201
+ };
202
+ }