aiden-runtime 4.1.1 → 4.1.3

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 (68) hide show
  1. package/README.md +78 -26
  2. package/dist/cli/v4/aidenCLI.js +169 -9
  3. package/dist/cli/v4/callbacks.js +20 -2
  4. package/dist/cli/v4/chatSession.js +644 -16
  5. package/dist/cli/v4/commands/auth.js +6 -3
  6. package/dist/cli/v4/commands/doctor.js +23 -27
  7. package/dist/cli/v4/commands/help.js +4 -0
  8. package/dist/cli/v4/commands/index.js +10 -1
  9. package/dist/cli/v4/commands/model.js +30 -1
  10. package/dist/cli/v4/commands/reloadSoul.js +37 -0
  11. package/dist/cli/v4/commands/update.js +102 -0
  12. package/dist/cli/v4/defaultSoul.js +68 -2
  13. package/dist/cli/v4/display/capabilityCard.js +135 -0
  14. package/dist/cli/v4/display/sessionEndCard.js +127 -0
  15. package/dist/cli/v4/display/toolTrail.js +172 -0
  16. package/dist/cli/v4/display.js +492 -142
  17. package/dist/cli/v4/doctor.js +472 -58
  18. package/dist/cli/v4/doctorLiveness.js +65 -10
  19. package/dist/cli/v4/promotionPrompt.js +332 -0
  20. package/dist/cli/v4/providerBootSelector.js +144 -0
  21. package/dist/cli/v4/replyRenderer.js +311 -20
  22. package/dist/cli/v4/sessionSummaryGate.js +66 -0
  23. package/dist/cli/v4/skinEngine.js +14 -3
  24. package/dist/cli/v4/toolPreview.js +153 -0
  25. package/dist/core/tools/nowPlaying.js +7 -15
  26. package/dist/core/v4/aidenAgent.js +91 -29
  27. package/dist/core/v4/capabilities.js +89 -0
  28. package/dist/core/v4/contextCompressor.js +25 -8
  29. package/dist/core/v4/distillationIndex.js +167 -0
  30. package/dist/core/v4/distillationStore.js +98 -0
  31. package/dist/core/v4/logger/logger.js +40 -9
  32. package/dist/core/v4/promotionCandidates.js +234 -0
  33. package/dist/core/v4/promptBuilder.js +145 -1
  34. package/dist/core/v4/sessionDistiller.js +452 -0
  35. package/dist/core/v4/skillMining/skillMiner.js +43 -6
  36. package/dist/core/v4/skillOutcomeTracker.js +323 -0
  37. package/dist/core/v4/subsystemHealth.js +143 -0
  38. package/dist/core/v4/toolRegistry.js +16 -1
  39. package/dist/core/v4/update/executeInstall.js +233 -0
  40. package/dist/core/version.js +1 -1
  41. package/dist/moat/memoryGuard.js +111 -0
  42. package/dist/moat/plannerGuard.js +19 -0
  43. package/dist/moat/skillTeacher.js +14 -5
  44. package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
  45. package/dist/providers/v4/errors.js +112 -4
  46. package/dist/providers/v4/modelDefaults.js +65 -0
  47. package/dist/providers/v4/registry.js +9 -2
  48. package/dist/providers/v4/runtimeResolver.js +6 -0
  49. package/dist/tools/v4/index.js +80 -1
  50. package/dist/tools/v4/memory/memoryRemove.js +57 -2
  51. package/dist/tools/v4/memory/sessionSummary.js +151 -0
  52. package/dist/tools/v4/sessions/recallSession.js +177 -0
  53. package/dist/tools/v4/sessions/sessionSearch.js +5 -1
  54. package/dist/tools/v4/system/_psHelpers.js +123 -0
  55. package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
  56. package/dist/tools/v4/system/appClose.js +79 -0
  57. package/dist/tools/v4/system/appInput.js +154 -0
  58. package/dist/tools/v4/system/appLaunch.js +218 -0
  59. package/dist/tools/v4/system/clipboardRead.js +54 -0
  60. package/dist/tools/v4/system/clipboardWrite.js +84 -0
  61. package/dist/tools/v4/system/mediaKey.js +109 -0
  62. package/dist/tools/v4/system/mediaSessions.js +163 -0
  63. package/dist/tools/v4/system/mediaTransport.js +211 -0
  64. package/dist/tools/v4/system/osProcessList.js +99 -0
  65. package/dist/tools/v4/system/screenshot.js +106 -0
  66. package/dist/tools/v4/system/volumeSet.js +157 -0
  67. package/package.json +4 -1
  68. package/skills/system_control.md +185 -69
@@ -0,0 +1,452 @@
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
+ * core/v4/sessionDistiller.ts — Phase v4.1.2-memory-AB.
10
+ *
11
+ * Replaces the lossy 5-bullet auxiliary summary with a structured
12
+ * SessionDistillation:
13
+ *
14
+ * - bullets[] (5 bullets, back-compat with MEMORY.md `## Recent sessions`)
15
+ * - decisions[] (higher-fidelity than bullets)
16
+ * - open_items[] (unfinished work, useful for next session)
17
+ * - keywords[] (for future retrieval ranking — Phase C)
18
+ * - files_touched[] (DETERMINISTIC — derived from tool-call result payloads)
19
+ * - tools_used[] (DETERMINISTIC — counted from tool-call trace names)
20
+ * - schema_version (always 1; reserved for future migrations)
21
+ * - exit_path (which exit caused the distillation: quit/sigint/etc.)
22
+ * - partial (set true when LLM JSON parse falls back to bullets-only)
23
+ *
24
+ * Source-of-truth split:
25
+ * - Programmatic fields (files_touched, tools_used) → trace inspection.
26
+ * - Semantic fields (bullets, decisions, open_items, keywords) → single
27
+ * auxiliary-LLM call with strict-then-lenient JSON parsing.
28
+ *
29
+ * Phase A's CLI ChatSession owns the per-session HonestyTraceEntry[]
30
+ * accumulator and passes it here. The auxiliary call sees the full
31
+ * message history (not the trace — the trace is purely for programmatic
32
+ * field derivation).
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.TOOL_RESULT_TRUNCATION = exports.SESSION_DISTILLATION_SCHEMA_VERSION = void 0;
36
+ exports.deriveProgrammaticFields = deriveProgrammaticFields;
37
+ exports.parseLLMDistillation = parseLLMDistillation;
38
+ exports.filterMessagesForDistillation = filterMessagesForDistillation;
39
+ exports.distillSession = distillSession;
40
+ // ── Public surface ───────────────────────────────────────────────────────
41
+ exports.SESSION_DISTILLATION_SCHEMA_VERSION = 1;
42
+ // ── Programmatic field derivation ─────────────────────────────────────────
43
+ /**
44
+ * Tools whose result payload SHOULD contain a `path` field naming the
45
+ * file they touched. Used to populate `files_touched`.
46
+ *
47
+ * Curated rather than "any tool with a path in its result" because
48
+ * read-only tools (`file_read`, `file_list`) shouldn't count as
49
+ * "touched" — only mutating ops do.
50
+ */
51
+ const FILE_TOUCH_TOOLS = new Set([
52
+ 'file_write',
53
+ 'file_patch',
54
+ 'file_create',
55
+ 'file_delete',
56
+ 'memory_add', // writes MEMORY.md / USER.md
57
+ 'memory_remove',
58
+ 'memory_replace',
59
+ 'session_summary', // writes MEMORY.md
60
+ ]);
61
+ /**
62
+ * Extract programmatic fields from the accumulated tool trace. Pure
63
+ * function — no I/O.
64
+ */
65
+ function deriveProgrammaticFields(trace) {
66
+ // tools_used: count by name, sorted by count desc, name asc.
67
+ const counts = new Map();
68
+ for (const e of trace) {
69
+ counts.set(e.name, (counts.get(e.name) ?? 0) + 1);
70
+ }
71
+ const tools_used = Array.from(counts.entries())
72
+ .map(([name, count]) => ({ name, count }))
73
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
74
+ // files_touched: unique paths from mutating tool results.
75
+ // Each entry's `result` may be { success, path, ... } or { path: ... }
76
+ // depending on the tool. We accept either shape.
77
+ const paths = new Set();
78
+ for (const e of trace) {
79
+ if (e.error)
80
+ continue; // failed tool — don't credit
81
+ if (!FILE_TOUCH_TOOLS.has(e.name))
82
+ continue;
83
+ const candidate = extractPath(e.result);
84
+ if (candidate)
85
+ paths.add(candidate);
86
+ }
87
+ const files_touched = Array.from(paths).sort();
88
+ return { files_touched, tools_used };
89
+ }
90
+ function extractPath(result) {
91
+ if (!result || typeof result !== 'object')
92
+ return null;
93
+ // Top-level path field — most write tools.
94
+ const top = result.path;
95
+ if (typeof top === 'string' && top.length > 0)
96
+ return top;
97
+ // Nested under .result (some adapters wrap output).
98
+ const inner = result.result;
99
+ if (inner && typeof inner === 'object') {
100
+ const innerPath = inner.path;
101
+ if (typeof innerPath === 'string' && innerPath.length > 0)
102
+ return innerPath;
103
+ }
104
+ return null;
105
+ }
106
+ // ── LLM extraction ────────────────────────────────────────────────────────
107
+ /**
108
+ * Strict-then-lenient parser for the auxiliary LLM's distillation JSON.
109
+ *
110
+ * Strict path: parse as JSON, validate shape, return all four semantic
111
+ * fields. Lenient path (only when strict fails): try to extract a
112
+ * bullets array from a malformed body (codepath shared with slice2's
113
+ * parseSessionBulletsResponse fallback), set the other three fields to
114
+ * empty arrays, and signal `partial: true` to the caller.
115
+ *
116
+ * Pure function — no I/O. Caller decides what to do with `partial`.
117
+ */
118
+ function parseLLMDistillation(raw) {
119
+ const trimmed = raw.trim();
120
+ if (!trimmed) {
121
+ return { bullets: [], decisions: [], open_items: [], keywords: [], partial: true };
122
+ }
123
+ // Strict path.
124
+ const strict = tryStrictParse(trimmed);
125
+ if (strict)
126
+ return { ...strict, partial: false };
127
+ // Lenient: scan for a JSON object embedded in prose (some models
128
+ // prefix "Here is the JSON:\n{...}"). Trim to the first '{' through
129
+ // the last '}' and retry.
130
+ const first = trimmed.indexOf('{');
131
+ const last = trimmed.lastIndexOf('}');
132
+ if (first >= 0 && last > first) {
133
+ const inner = trimmed.slice(first, last + 1);
134
+ const second = tryStrictParse(inner);
135
+ if (second)
136
+ return { ...second, partial: false };
137
+ }
138
+ // Bullets-only fallback — recover what we can. Tries a bare bullet
139
+ // list ("- ...", "* ...", numbered lines) or a JSON-array fragment.
140
+ const fallbackBullets = recoverBullets(trimmed);
141
+ return {
142
+ bullets: fallbackBullets,
143
+ decisions: [],
144
+ open_items: [],
145
+ keywords: [],
146
+ partial: true,
147
+ };
148
+ }
149
+ function tryStrictParse(s) {
150
+ try {
151
+ const obj = JSON.parse(s);
152
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj))
153
+ return null;
154
+ const o = obj;
155
+ const bullets = toStringArray(o.bullets);
156
+ const decisions = toStringArray(o.decisions);
157
+ const open_items = toStringArray(o.open_items ?? o.openItems);
158
+ const keywords = toStringArray(o.keywords);
159
+ if (bullets.length === 0 && decisions.length === 0 && open_items.length === 0) {
160
+ return null; // nothing useful — let the lenient path try
161
+ }
162
+ return { bullets, decisions, open_items, keywords };
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ function toStringArray(v) {
169
+ if (!Array.isArray(v))
170
+ return [];
171
+ return v
172
+ .filter((x) => typeof x === 'string')
173
+ .map((x) => x.trim())
174
+ .filter((x) => x.length > 0);
175
+ }
176
+ function recoverBullets(raw) {
177
+ // Strategy 1: bullet-prefixed lines.
178
+ const lines = raw.split(/\r?\n/);
179
+ const bulleted = lines
180
+ .map((l) => l.replace(/^\s*(?:[-*•]|\d+\.)\s+/, '').trim())
181
+ .filter((l, i, arr) => l.length > 0 && /^\s*(?:[-*•]|\d+\.)\s+/.test(lines[i] ?? ''));
182
+ if (bulleted.length > 0)
183
+ return bulleted.slice(0, 5);
184
+ // Strategy 2: a JSON array of strings, with or without the object wrapper.
185
+ const arrMatch = raw.match(/\[\s*"[\s\S]*?"\s*\]/);
186
+ if (arrMatch) {
187
+ try {
188
+ const arr = JSON.parse(arrMatch[0]);
189
+ return toStringArray(arr).slice(0, 5);
190
+ }
191
+ catch { /* fall through */ }
192
+ }
193
+ return [];
194
+ }
195
+ /**
196
+ * v4.1.3-essentials distillation-fix: default raised from 4000ms to
197
+ * 12000ms after visual smoke showed chatgpt-plus Codex regularly
198
+ * exceeded the original budget for 800-token summaries on
199
+ * cold-start. Symptom: every `/quit` distillation returned
200
+ * `partial:true` with empty bullets/decisions/open_items, killing
201
+ * both the MEMORY.md update path AND the promotion prompt.
202
+ *
203
+ * 12s gives comfortable headroom while still aborting genuinely
204
+ * stuck calls. Power users can override via `AIDEN_SUMMARY_TIMEOUT_MS`
205
+ * env var (consumed by `resolveSummaryTimeoutMs()` in chatSession).
206
+ */
207
+ const DEFAULT_TIMEOUT_MS = 12000;
208
+ /**
209
+ * Phase v4.1.2-bug-Y: max chars of tool-result content surfaced to the
210
+ * auxiliary LLM. Covers typical error messages + JSON-payload heads
211
+ * without bloating the prompt with full tool-output dumps. User and
212
+ * assistant TEXT are never truncated — user intent must survive in
213
+ * full. Widen this only after eval shows truncation eating signal.
214
+ */
215
+ exports.TOOL_RESULT_TRUNCATION = 200;
216
+ /**
217
+ * Pure: filter + format the conversation history into the transcript
218
+ * the auxiliary LLM sees. Phase v4.1.2-bug-Y root-cause fix:
219
+ *
220
+ * The previous distiller dumped chatSession.history verbatim,
221
+ * including the giant `role: 'system'` block PromptBuilder
222
+ * constructs (SOUL.md identity, MEMORY.md, USER.md, Runtime slot,
223
+ * Capabilities boilerplate, tool-catalog descriptions, personality
224
+ * overlay, execution-discipline notes). Weak summarizer models
225
+ * latched onto this longest-coherent-block in context as the
226
+ * session topic, returning bullets like "I'm Aiden, a local-first
227
+ * AI agent built by Taracod" regardless of what the user and
228
+ * assistant actually discussed.
229
+ *
230
+ * This filter drops ALL `role: 'system'` messages and emits the
231
+ * remaining traffic as role-tagged lines:
232
+ *
233
+ * [USER] full user message verbatim
234
+ * [ASSISTANT] assistant text (if non-empty)
235
+ * [TOOL:name] {args}
236
+ * [TOOL:name] → result-payload, truncated to TOOL_RESULT_TRUNCATION
237
+ *
238
+ * Tool results carry their tool name (resolved via toolCallId →
239
+ * call-name map walked through preceding assistant turns) so the
240
+ * model can correlate tool intent with output. Empty messages are
241
+ * dropped entirely. Multi-line content within a message is
242
+ * preserved.
243
+ */
244
+ function filterMessagesForDistillation(messages) {
245
+ /** Per-toolCallId → toolName, populated as we walk assistant turns. */
246
+ const callNames = new Map();
247
+ const lines = [];
248
+ for (const m of messages) {
249
+ if (m.role === 'system')
250
+ continue; // entire boilerplate source — dropped
251
+ if (m.role === 'user') {
252
+ const text = m.content.trim();
253
+ if (text.length === 0)
254
+ continue;
255
+ lines.push(`[USER] ${text}`);
256
+ continue;
257
+ }
258
+ if (m.role === 'assistant') {
259
+ // Emit assistant text only if non-empty — avoid empty `[ASSISTANT]`
260
+ // placeholder for tool-only turns.
261
+ const text = (m.content ?? '').trim();
262
+ if (text.length > 0)
263
+ lines.push(`[ASSISTANT] ${text}`);
264
+ // Tool calls: cache the id → name pair so the matching tool
265
+ // result downstream can render with its tool name. Emit the
266
+ // call line in original order.
267
+ if (m.toolCalls && m.toolCalls.length > 0) {
268
+ for (const tc of m.toolCalls) {
269
+ callNames.set(tc.id, tc.name);
270
+ const argsStr = compactArgs(tc.arguments);
271
+ lines.push(`[TOOL:${tc.name}] ${argsStr}`);
272
+ }
273
+ }
274
+ continue;
275
+ }
276
+ if (m.role === 'tool') {
277
+ const name = callNames.get(m.toolCallId) ?? 'unknown';
278
+ const truncated = truncateForTranscript(m.content);
279
+ lines.push(`[TOOL:${name}] → ${truncated}`);
280
+ continue;
281
+ }
282
+ }
283
+ return lines.join('\n');
284
+ }
285
+ /**
286
+ * Compact tool-call args into a one-line representation. JSON shape
287
+ * preserved; large strings get truncated alongside everything else
288
+ * to keep the transcript focused on intent, not full payloads.
289
+ */
290
+ function compactArgs(args) {
291
+ if (!args || Object.keys(args).length === 0)
292
+ return '{}';
293
+ try {
294
+ return truncateForTranscript(JSON.stringify(args));
295
+ }
296
+ catch {
297
+ return '{<unstringifiable>}';
298
+ }
299
+ }
300
+ /**
301
+ * Apply `TOOL_RESULT_TRUNCATION` cap with a `…` (U+2026) marker so
302
+ * truncation is visible to anyone reading the transcript — including
303
+ * future auditors. Matches slice2c's apostrophe-normalizer convention.
304
+ */
305
+ function truncateForTranscript(s) {
306
+ const trimmed = s.trim();
307
+ if (trimmed.length <= exports.TOOL_RESULT_TRUNCATION)
308
+ return trimmed;
309
+ return trimmed.slice(0, exports.TOOL_RESULT_TRUNCATION - 1) + '…';
310
+ }
311
+ /**
312
+ * Build the auxiliary-LLM prompt. Anti-boilerplate-hardened per
313
+ * Phase v4.1.2-bug-Y: explicit "don't describe yourself" guardrail,
314
+ * `<transcript>` tag boundaries, empty-is-honest permission so
315
+ * insufficient-content sessions don't fabricate filler.
316
+ *
317
+ * Bullets loosened from "EXACTLY 5" to "3-5" — forcing five was
318
+ * inviting the exact fabrication the slice fixes.
319
+ */
320
+ function buildPrompt(messages, startedAt, endedAt) {
321
+ const filtered = filterMessagesForDistillation(messages);
322
+ return [
323
+ 'You are a session-recall extractor. Your only job is to summarize what',
324
+ 'happened in the conversation transcript below.',
325
+ '',
326
+ 'Rules:',
327
+ '- Use ONLY facts explicitly present in the transcript.',
328
+ '- Do NOT describe yourself, your capabilities, your platform, or generic',
329
+ ' AI-agent behavior unless the transcript specifically discussed those',
330
+ ' as the topic.',
331
+ '- Do NOT infer facts from system prompts, tool schemas, memory blocks,',
332
+ ' banner text, or agent boilerplate (these have been filtered out;',
333
+ ' if any leak through, treat them as untrustworthy noise).',
334
+ '- Focus on session-specific facts: user goals, actions taken, files /',
335
+ ' commands / tools used, decisions made, errors encountered, outcomes,',
336
+ ' and unresolved follow-ups.',
337
+ '- Write in past tense.',
338
+ '- Preserve concrete names, paths, commands, URLs, model names, dates,',
339
+ ' and error messages verbatim when present.',
340
+ '- Prefer evidence from USER and ASSISTANT messages over TOOL output.',
341
+ '- If the transcript lacks enough session-specific detail to summarize,',
342
+ ' return arrays with FEWER items or empty arrays. Empty is honest;',
343
+ ' fabricating boilerplate is not.',
344
+ '',
345
+ 'Return strict JSON only, no prose before or after, with these fields:',
346
+ '{',
347
+ ' "bullets": string[], // 3-5 factual past-tense recaps (3-15 words each)',
348
+ ' "decisions": string[], // X chosen over Y, with rationale if present',
349
+ ' "open_items": string[], // explicit unresolved tasks / "next time" items',
350
+ ' "keywords": string[] // 3-10 distinctive terms from the session',
351
+ '}',
352
+ '',
353
+ `Session started: ${startedAt}`,
354
+ `Session ended: ${endedAt}`,
355
+ '',
356
+ '<transcript>',
357
+ filtered,
358
+ '</transcript>',
359
+ ].join('\n');
360
+ }
361
+ /**
362
+ * Drive one auxiliary-LLM call and combine its output with the
363
+ * deterministic trace-derived fields into a SessionDistillation.
364
+ *
365
+ * Respects `timeoutMs` (default DEFAULT_TIMEOUT_MS) via Promise.race;
366
+ * on timeout the LLM result is treated as empty (partial: true with
367
+ * empty semantic fields). Deterministic fields always populate
368
+ * regardless of LLM outcome — the distillation is never empty.
369
+ */
370
+ async function distillSession(opts) {
371
+ const endedAt = opts.endedAt ?? new Date().toISOString();
372
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
373
+ const programmatic = deriveProgrammaticFields(opts.toolTrace);
374
+ // Run the auxiliary call under a hard timeout. The race resolves
375
+ // with `{timedOut: true}` if the LLM doesn't return in time — we
376
+ // record that as a partial distillation.
377
+ const prompt = buildPrompt(opts.messages, opts.startedAt, endedAt);
378
+ const llmRaw = await Promise.race([
379
+ opts.auxiliaryClient
380
+ .call({ purpose: 'session_summary', prompt, maxTokens: 800 })
381
+ .then((r) => ({ ok: true, content: r.content ?? '' }))
382
+ .catch((e) => ({ ok: false, error: e })),
383
+ new Promise((resolve) => {
384
+ setTimeout(() => resolve({ ok: false, error: new Error(`auxiliary call timed out after ${timeoutMs}ms`), timedOut: true }), timeoutMs);
385
+ }),
386
+ ]);
387
+ // v4.1.3-essentials distillation-fix: emit a diagnostic line for
388
+ // each of the three failure classes so the caller can surface the
389
+ // root cause. Previously all three paths produced an identical
390
+ // `partial:true + empty` result with no signal about WHICH failure
391
+ // fired. Safe to call onDiagnostic synchronously — caller wraps in
392
+ // try/catch so a throwing sink doesn't break distillation.
393
+ const diag = (msg) => {
394
+ if (!opts.onDiagnostic)
395
+ return;
396
+ try {
397
+ opts.onDiagnostic(msg);
398
+ }
399
+ catch { /* never break distillation */ }
400
+ };
401
+ let semantic;
402
+ if (llmRaw.ok) {
403
+ semantic = parseLLMDistillation(llmRaw.content);
404
+ if (semantic.partial) {
405
+ // Parser fell back to bullets-only or fully-empty — the LLM
406
+ // returned content but it wasn't valid JSON. First-200-chars
407
+ // hint lets the user / debugger see what shape the model
408
+ // actually emitted (often a chatty preamble that confused
409
+ // the JSON extractor).
410
+ const head = llmRaw.content.trim().slice(0, 200).replace(/\n/g, ' ');
411
+ diag(`auxiliary returned unparseable JSON (first 200 chars: ${head})`);
412
+ }
413
+ }
414
+ else {
415
+ // Race resolved with the failure branch — either the timeout
416
+ // fired or auxiliaryClient.call threw. Hoist `error` into a
417
+ // local so the narrowed type stays stable inside the branch
418
+ // (TS can't infer `error` exists on `llmRaw` because the union
419
+ // overlaps with the success branch in its type literal).
420
+ const failure = llmRaw;
421
+ if (failure.timedOut === true) {
422
+ diag(`auxiliary call timed out after ${timeoutMs}ms`);
423
+ }
424
+ else {
425
+ diag(`auxiliary call failed: ${failure.error.message}`);
426
+ }
427
+ semantic = {
428
+ bullets: [],
429
+ decisions: [],
430
+ open_items: [],
431
+ keywords: [],
432
+ partial: true,
433
+ };
434
+ }
435
+ const dist = {
436
+ schema_version: exports.SESSION_DISTILLATION_SCHEMA_VERSION,
437
+ session_id: opts.sessionId,
438
+ started_at: opts.startedAt,
439
+ ended_at: endedAt,
440
+ exit_path: opts.exitPath,
441
+ user_turns: opts.userTurns,
442
+ bullets: semantic.bullets,
443
+ decisions: semantic.decisions,
444
+ open_items: semantic.open_items,
445
+ keywords: semantic.keywords,
446
+ files_touched: programmatic.files_touched,
447
+ tools_used: programmatic.tools_used,
448
+ };
449
+ if (semantic.partial)
450
+ dist.partial = true;
451
+ return dist;
452
+ }
@@ -101,6 +101,7 @@ class SkillMiner {
101
101
  this.auxiliaryClient = opts.auxiliaryClient;
102
102
  this.sessionCap = opts.sessionCap ?? exports.SESSION_SKILL_LIMIT;
103
103
  this.skeletonOnly = opts.skeletonOnly ?? false;
104
+ this.healthTracker = opts.healthTracker;
104
105
  }
105
106
  /** Test/reset hook. */
106
107
  _resetForTests() {
@@ -133,14 +134,32 @@ class SkillMiner {
133
134
  return { status: 'opt-out' };
134
135
  }
135
136
  }
136
- // Fingerprint + dedup.
137
+ // Fingerprint + dedup. Phase v4.1.2-slice3: wrap the store reads
138
+ // so disk-level failures (read of pending list, read of rejected
139
+ // list) surface to the health tracker. We re-throw to preserve
140
+ // existing crash-on-disk-error semantics — the surface is purely
141
+ // observational.
137
142
  const fpEntries = obs.trace.map((e) => ({ name: e.name, args: e.args }));
138
143
  const fingerprint = (0, traceFingerprint_1.traceFingerprint)(fpEntries);
139
- const pending = await this.store.list();
144
+ let pending;
145
+ try {
146
+ pending = await this.store.list();
147
+ }
148
+ catch (e) {
149
+ this.healthTracker?.recordFailure(e);
150
+ throw e;
151
+ }
140
152
  if (pending.some((c) => c.fingerprint === fingerprint)) {
141
153
  return { status: 'dedup-pending' };
142
154
  }
143
- const rejected = await this.store.loadRejected();
155
+ let rejected;
156
+ try {
157
+ rejected = await this.store.loadRejected();
158
+ }
159
+ catch (e) {
160
+ this.healthTracker?.recordFailure(e);
161
+ throw e;
162
+ }
144
163
  if (rejected.has(fingerprint)) {
145
164
  return { status: 'dedup-rejected' };
146
165
  }
@@ -163,7 +182,17 @@ class SkillMiner {
163
182
  };
164
183
  let skill = (0, proposalBuilder_1.draft)(obs.trace, ctx);
165
184
  if (!this.skeletonOnly) {
166
- skill = await (0, extractorPrompt_1.refine)(skill, { client: this.auxiliaryClient });
185
+ // Phase v4.1.2-slice3: refine is an LLM call that historically
186
+ // crashed the whole turn when it threw. Wrap and surface; on
187
+ // failure fall back to the skeleton so mining still produces
188
+ // something rather than nothing.
189
+ try {
190
+ skill = await (0, extractorPrompt_1.refine)(skill, { client: this.auxiliaryClient });
191
+ }
192
+ catch (e) {
193
+ this.healthTracker?.recordFailure(e);
194
+ // Skeleton retained; carry on.
195
+ }
167
196
  }
168
197
  // Final validation — must round-trip through parseSkillContent
169
198
  // (the canonical loader parser). If it doesn't, drop the
@@ -171,7 +200,8 @@ class SkillMiner {
171
200
  try {
172
201
  (0, skillSpec_1.parseSkillContent)(skill);
173
202
  }
174
- catch {
203
+ catch (e) {
204
+ this.healthTracker?.recordFailure(e);
175
205
  return { status: 'invalid-skill', confidence };
176
206
  }
177
207
  const candidate = {
@@ -183,8 +213,15 @@ class SkillMiner {
183
213
  candidateConfidence: confidence,
184
214
  skillContent: skill,
185
215
  };
186
- await this.store.append(candidate);
216
+ try {
217
+ await this.store.append(candidate);
218
+ }
219
+ catch (e) {
220
+ this.healthTracker?.recordFailure(e);
221
+ throw e;
222
+ }
187
223
  this.perSessionCount.set(obs.sessionId, this.countForSession(obs.sessionId) + 1);
224
+ this.healthTracker?.recordSuccess();
188
225
  return { status: 'queued', candidate, confidence };
189
226
  }
190
227
  }