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,233 @@
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/update/executeInstall.ts — Phase v4.1.2-update.
10
+ *
11
+ * Shared in-process installer for `npm install -g aiden-runtime@latest`.
12
+ * Used by two surfaces:
13
+ * - `/update install` slash command (cli/v4/commands/update.ts)
14
+ * - `aiden_self_update` tool (tools/v4/system/aidenSelfUpdate.ts)
15
+ *
16
+ * Both call this single executor so install behavior — timeout,
17
+ * permission-denied fallback, version detection — has ONE source of
18
+ * truth. Future v4.1.3+ rollback / package-manager-swap work edits
19
+ * one file.
20
+ *
21
+ * Behavior:
22
+ * - Spawns `npm install -g aiden-runtime@latest` with INSTALL_TIMEOUT_MS
23
+ * wall-clock cap.
24
+ * - Captures stdout/stderr; returns both for diagnostics regardless
25
+ * of outcome.
26
+ * - Detects the installed version from npm's `+ aiden-runtime@x.y.z`
27
+ * output line; null when not parseable.
28
+ * - On permission-denied (EACCES / "EACCES" / Windows ENOPRIV /
29
+ * "operation not permitted"): returns structured failure with
30
+ * platform-specific copy-paste commands so the user can run the
31
+ * install manually with proper privileges.
32
+ *
33
+ * Honest about what it doesn't do:
34
+ * - No auto-restart of the running REPL. The currently-running
35
+ * process keeps running the OLD version regardless of what npm
36
+ * just installed globally — claiming otherwise would lie to the
37
+ * user. Caller prints the "type /quit and rerun aiden" hint
38
+ * instead so the user knows exactly when the new version takes
39
+ * effect.
40
+ * - No self-escalation to UAC/sudo. We try once; on permission
41
+ * failure we surface the right copy-paste, not silent escalation.
42
+ * - No registry probe — call `checkForUpdate` first if you need to
43
+ * know whether an install is warranted.
44
+ */
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.INSTALL_TIMEOUT_MS = void 0;
47
+ exports.executeInstall = executeInstall;
48
+ exports.parseInstalledVersion = parseInstalledVersion;
49
+ const node_child_process_1 = require("node:child_process");
50
+ /** 90 s wall-clock cap. Generous on cold caches / slow networks. */
51
+ exports.INSTALL_TIMEOUT_MS = 90000;
52
+ const DEFAULT_PACKAGE_SPEC = 'aiden-runtime@latest';
53
+ /**
54
+ * Run the install. Returns a structured result; NEVER throws — the
55
+ * outer surface (slash command / tool) renders the result to the user.
56
+ *
57
+ * Error path is intentionally string-typed (single user-visible
58
+ * paragraph). The structured fields (stdout/stderr/exitCode) are for
59
+ * diagnostics; callers that want to surface them to the user can
60
+ * compose their own message from those.
61
+ */
62
+ async function executeInstall(opts = {}) {
63
+ const spawn = opts.spawnImpl ?? node_child_process_1.spawn;
64
+ const timeoutMs = opts.timeoutMs ?? exports.INSTALL_TIMEOUT_MS;
65
+ const packageSpec = opts.packageSpec ?? DEFAULT_PACKAGE_SPEC;
66
+ const platform = opts.platform ?? process.platform;
67
+ return new Promise((resolve) => {
68
+ const args = ['install', '-g', packageSpec];
69
+ // shell: true on Windows so npm.cmd is found via PATHEXT; on
70
+ // POSIX we spawn npm directly. Either way the args are validated
71
+ // (only npm + install + a hardcoded spec by default) — no user
72
+ // input flows into argv.
73
+ const spawnOpts = {
74
+ shell: platform === 'win32',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ };
77
+ let child;
78
+ try {
79
+ child = spawn('npm', args, spawnOpts);
80
+ }
81
+ catch (err) {
82
+ resolve({
83
+ success: false,
84
+ error: `Could not launch npm: ${err.message}. ` +
85
+ `Run \`npm install -g aiden-runtime@latest\` manually.`,
86
+ });
87
+ return;
88
+ }
89
+ let stdoutBuf = '';
90
+ let stderrBuf = '';
91
+ child.stdout?.on('data', (chunk) => {
92
+ stdoutBuf += chunk.toString();
93
+ });
94
+ child.stderr?.on('data', (chunk) => {
95
+ stderrBuf += chunk.toString();
96
+ });
97
+ // Timeout — kill the child + resolve as a failure with the captured
98
+ // output so the user sees what npm was doing.
99
+ let timedOut = false;
100
+ const timer = setTimeout(() => {
101
+ timedOut = true;
102
+ try {
103
+ child.kill('SIGTERM');
104
+ }
105
+ catch { /* ignore */ }
106
+ }, timeoutMs);
107
+ child.on('error', (err) => {
108
+ clearTimeout(timer);
109
+ // Spawn-level error (ENOENT — npm not on PATH).
110
+ resolve({
111
+ success: false,
112
+ error: `npm spawn failed: ${err.message}. Is npm installed and on PATH?`,
113
+ stderr: stderrBuf,
114
+ stdout: stdoutBuf,
115
+ });
116
+ });
117
+ child.on('close', (code) => {
118
+ clearTimeout(timer);
119
+ const stdout = stdoutBuf;
120
+ const stderr = stderrBuf;
121
+ const exitCode = code ?? -1;
122
+ if (timedOut) {
123
+ resolve({
124
+ success: false,
125
+ error: `Install timed out after ${timeoutMs}ms. ` +
126
+ `Try \`npm install -g aiden-runtime@latest\` manually.`,
127
+ stdout, stderr, exitCode: -1,
128
+ });
129
+ return;
130
+ }
131
+ // Permission-denied: surface platform-specific remediations.
132
+ if (isPermissionDenied(stdout, stderr, exitCode)) {
133
+ resolve({
134
+ success: false,
135
+ error: permissionDeniedMessage(platform),
136
+ stdout, stderr, exitCode,
137
+ });
138
+ return;
139
+ }
140
+ if (exitCode !== 0) {
141
+ resolve({
142
+ success: false,
143
+ error: `Install failed (npm exit ${exitCode}). ` +
144
+ (stderr.trim().slice(0, 200) ||
145
+ 'See stderr/stdout for details. Try `npm install -g aiden-runtime@latest` manually.'),
146
+ stdout, stderr, exitCode,
147
+ });
148
+ return;
149
+ }
150
+ // Success — parse installed version from npm output. Pattern:
151
+ // "+ aiden-runtime@4.1.3" or "added 1 package ... aiden-runtime@4.1.3"
152
+ const installedVersion = parseInstalledVersion(stdout) ?? parseInstalledVersion(stderr) ?? undefined;
153
+ resolve({
154
+ success: true,
155
+ installedVersion,
156
+ stdout, stderr, exitCode,
157
+ });
158
+ });
159
+ });
160
+ }
161
+ // ── Helpers ───────────────────────────────────────────────────────────────
162
+ /**
163
+ * Did npm fail because of a permission error? Heuristics across the
164
+ * three platforms — npm doesn't return a single canonical exit code
165
+ * for this, so we sniff the captured streams.
166
+ */
167
+ function isPermissionDenied(stdout, stderr, exitCode) {
168
+ const haystack = `${stderr}\n${stdout}`.toLowerCase();
169
+ // POSIX: "EACCES", "permission denied", "operation not permitted"
170
+ if (haystack.includes('eacces'))
171
+ return true;
172
+ if (haystack.includes('permission denied'))
173
+ return true;
174
+ if (haystack.includes('operation not permitted'))
175
+ return true;
176
+ // Windows: usually exit 1 with stderr containing "EPERM" or
177
+ // "operation not permitted" or "access is denied"
178
+ if (haystack.includes('eperm'))
179
+ return true;
180
+ if (haystack.includes('access is denied'))
181
+ return true;
182
+ // exit 243 is the npm conventional "permission" code on some setups;
183
+ // we don't gate on exit code alone (too noisy) but combined with
184
+ // any of the above strings it's a clear signal.
185
+ void exitCode;
186
+ return false;
187
+ }
188
+ /**
189
+ * Build the platform-specific copy-paste remediation. Provides three
190
+ * distinct paths — system-wide-with-elevation (Windows admin),
191
+ * sudo (macOS/Linux), or user-local-prefix (cross-platform) — so the
192
+ * user has options without us trying to self-escalate to UAC/sudo
193
+ * from inside the running REPL.
194
+ */
195
+ function permissionDeniedMessage(platform) {
196
+ const userLocal = 'Or use a user-local npm prefix to avoid privileges entirely:\n' +
197
+ ' npm config set prefix ~/.npm-global\n' +
198
+ ' export PATH=~/.npm-global/bin:$PATH # add to your shell profile\n' +
199
+ ' npm install -g aiden-runtime@latest';
200
+ if (platform === 'win32') {
201
+ return [
202
+ 'Install failed: permission denied (npm needs Administrator for global install).',
203
+ '',
204
+ 'To update manually:',
205
+ ' Windows: Open PowerShell as Administrator, then:',
206
+ ' npm install -g aiden-runtime@latest',
207
+ '',
208
+ userLocal,
209
+ ].join('\n');
210
+ }
211
+ // darwin / linux / others — sudo path.
212
+ return [
213
+ 'Install failed: permission denied (npm needs sudo for global install).',
214
+ '',
215
+ 'To update manually:',
216
+ ' macOS / Linux:',
217
+ ' sudo npm install -g aiden-runtime@latest',
218
+ '',
219
+ userLocal,
220
+ ].join('\n');
221
+ }
222
+ /**
223
+ * Find the installed version in npm output. Two common patterns:
224
+ * "+ aiden-runtime@4.1.3"
225
+ * "added 1 package in 12s ... aiden-runtime@4.1.3"
226
+ * Returns the bare version string (no `v` prefix) or null.
227
+ */
228
+ function parseInstalledVersion(out) {
229
+ if (!out)
230
+ return null;
231
+ const m = out.match(/aiden-runtime@(\d+\.\d+\.\d+(?:-[a-z0-9.]+)?)/i);
232
+ return m ? m[1] : null;
233
+ }
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // AUTO-GENERATED by scripts/inject-version.js — do not edit by hand
5
- exports.VERSION = '4.1.1';
5
+ exports.VERSION = '4.1.3';
@@ -31,6 +31,7 @@
31
31
  */
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.MemoryGuard = void 0;
34
+ exports.containsInSection = containsInSection;
34
35
  class MemoryGuard {
35
36
  constructor(memory) {
36
37
  this.memory = memory;
@@ -113,6 +114,75 @@ class MemoryGuard {
113
114
  }
114
115
  return { ok: true, verified: true, fileLength: text.length };
115
116
  }
117
+ /**
118
+ * Phase v4.1.2 alive-core: section-aware write. Replaces the body of
119
+ * a markdown `## <header>` section, creating the section at file end
120
+ * if it doesn't yet exist. Body lines below the header up to the
121
+ * next `## ` (or EOF) are replaced wholesale.
122
+ *
123
+ * Preserves the standard verify-on-disk contract: the post-write read
124
+ * confirms `newBody` is present and (when applicable) the previous
125
+ * section body is gone before returning `verified: true`.
126
+ *
127
+ * Additive — does not change `guardedAdd` / `guardedReplace` /
128
+ * `guardedRemove` semantics.
129
+ */
130
+ async replaceSection(file, header, newBody) {
131
+ const headerTrim = header.trim();
132
+ if (!headerTrim.startsWith('## ')) {
133
+ return {
134
+ ok: false,
135
+ verified: false,
136
+ reason: 'header must start with "## " (markdown h2)',
137
+ };
138
+ }
139
+ const bodyTrim = newBody.trim();
140
+ if (!bodyTrim) {
141
+ return {
142
+ ok: false,
143
+ verified: false,
144
+ reason: 'newBody cannot be empty. Use guardedRemove() to drop a section.',
145
+ };
146
+ }
147
+ // Read current file state so we can compute the precise old block.
148
+ const snapBefore = await this.memory.loadSnapshot();
149
+ const before = pickFile(snapBefore, file);
150
+ // Match `<header>` and everything below it up to the next `## `
151
+ // line or EOF. No `m` flag — we want `$` to mean end-of-string;
152
+ // with `m`, the trailing-`$` lookahead would match at every line
153
+ // ending and chop off all but the first body line.
154
+ const escapedHeader = headerTrim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
155
+ const sectionRe = new RegExp(`${escapedHeader}[^\\n]*(?:\\r?\\n[\\s\\S]*?)?(?=\\n## |$)`);
156
+ const match = before.match(sectionRe);
157
+ const newSection = `${headerTrim}\n${bodyTrim}`;
158
+ let mutation;
159
+ if (match && match[0]) {
160
+ mutation = await this.memory.replace(file, match[0], newSection);
161
+ }
162
+ else {
163
+ // Section doesn't exist — append at end with a blank-line gap.
164
+ const sep = before.length > 0 && !before.endsWith('\n') ? '\n\n' : '\n';
165
+ mutation = await this.memory.add(file, `${sep}${newSection}`);
166
+ }
167
+ if (!mutation.ok) {
168
+ return {
169
+ ok: false,
170
+ verified: false,
171
+ reason: mutation.reason ?? 'section write failed',
172
+ };
173
+ }
174
+ const snapAfter = await this.memory.loadSnapshot();
175
+ const after = pickFile(snapAfter, file);
176
+ if (!after.includes(headerTrim) || !after.includes(bodyTrim)) {
177
+ return {
178
+ ok: false,
179
+ verified: false,
180
+ reason: 'Section write claimed but header/body not found post-write',
181
+ fileLength: after.length,
182
+ };
183
+ }
184
+ return { ok: true, verified: true, fileLength: after.length };
185
+ }
116
186
  async guardedRemove(file, text) {
117
187
  const trimmed = text.trim();
118
188
  if (!trimmed) {
@@ -144,3 +214,44 @@ exports.MemoryGuard = MemoryGuard;
144
214
  function pickFile(snap, file) {
145
215
  return file === 'user' ? snap.userMd : snap.memoryMd;
146
216
  }
217
+ /**
218
+ * Phase v4.1.2-bug-X: section-aware containment check.
219
+ *
220
+ * Returns `true` if `target` appears anywhere within the body of the
221
+ * section identified by `sectionHeader` (e.g. `"## Durable facts"`).
222
+ * The section body runs from the line after the header to the line
223
+ * before the next `## ` header — or end-of-file, whichever comes
224
+ * first. Returns `false` when the section doesn't exist OR when the
225
+ * target sits outside it.
226
+ *
227
+ * Pure: no I/O, deterministic from inputs. Used by `memory_remove`
228
+ * to protect user-approved durable facts from autonomous deletion:
229
+ * the model proposed substring-match against MEMORY.md, but
230
+ * substring removal operates whole-file — partial protection would
231
+ * still nuke the durable copy as side-effect. STRICT containment
232
+ * (rejects if the substring appears ANYWHERE in the section body)
233
+ * is the honest guard.
234
+ *
235
+ * Case-sensitive: matches the existing `guardedRemove` semantics
236
+ * which use `String.prototype.includes` directly on the raw content.
237
+ *
238
+ * @param fileContent Full file content (e.g. MEMORY.md as one string).
239
+ * @param target Substring the caller intends to remove.
240
+ * @param sectionHeader Header line including the `## ` prefix.
241
+ */
242
+ function containsInSection(fileContent, target, sectionHeader) {
243
+ if (!fileContent || !target || !sectionHeader)
244
+ return false;
245
+ const headerEscaped = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ // Match the header line, then capture the body until the next `## `
247
+ // (any h2) or end-of-string. No `m` flag — the trailing-`$` would
248
+ // otherwise match every line ending and chop the body at the first
249
+ // newline (the same trap the slice2 sessionSummary regex already
250
+ // documents).
251
+ const sectionRe = new RegExp(`${headerEscaped}[^\\n]*\\n([\\s\\S]*?)(?=\\n## |$)`);
252
+ const m = fileContent.match(sectionRe);
253
+ if (!m)
254
+ return false;
255
+ const body = m[1] ?? '';
256
+ return body.includes(target);
257
+ }
@@ -91,6 +91,25 @@ const RULES = [
91
91
  keywords: /\b(process|background|long.?running|server|spawn|kill|daemon)\b/i,
92
92
  toolsets: ['process'],
93
93
  },
94
+ // Media playback control (v4.1.4-media)
95
+ //
96
+ // Without this, intents like "list media sessions" matched the
97
+ // `sessions` rule via the bare word "session" → narrowed surface to
98
+ // toolset `sessions` only → `media_sessions` (toolset `system`) was
99
+ // filtered out and the model honestly reported it as unavailable.
100
+ // UNION semantics mean both rules contribute on phrases that hit
101
+ // both ("media sessions" → sessions + system), giving the model the
102
+ // full picture without needing a dedicated `media` toolset.
103
+ //
104
+ // Toolset is `system` (broad but minimal blast radius): the bundle
105
+ // covers media_sessions, media_transport, media_key, app_input,
106
+ // now_playing, plus the plausibly-relevant app_launch / volume_set /
107
+ // os_process_list. Carving out a dedicated `media` toolset is a
108
+ // separate slice if the surface noise becomes a real problem.
109
+ {
110
+ keywords: /\b(play|pause|skip|spotify|music|song|video|youtube|track|playback|media)\b/i,
111
+ toolsets: ['system'],
112
+ },
94
113
  ];
95
114
  /** Always-on tools regardless of mode. The agent needs schema lookup
96
115
  * + skill discovery + session search to be useful even on cold turns. */
@@ -97,7 +97,7 @@ class SkillTeacher {
97
97
  /** Optional handler-resolver to look up toolset metadata for trace
98
98
  * entries that don't carry their own toolset. Used for the 2-toolset
99
99
  * diversity check. */
100
- resolveHandler) {
100
+ resolveHandler, healthTracker) {
101
101
  this.skillLoader = skillLoader;
102
102
  this.skillManager = skillManager;
103
103
  this.resolveHandler = resolveHandler;
@@ -107,6 +107,7 @@ class SkillTeacher {
107
107
  this.qualityPath =
108
108
  qualityFilePath ??
109
109
  node_path_1.default.join(process.cwd(), '.aiden-skill-quality.json');
110
+ this.healthTracker = healthTracker;
110
111
  }
111
112
  setTier(tier) {
112
113
  this.tier = tier;
@@ -203,9 +204,11 @@ class SkillTeacher {
203
204
  name: proposal.proposedName,
204
205
  content,
205
206
  }, {});
207
+ this.healthTracker?.recordSuccess();
206
208
  return { created: true, skillName: proposal.proposedName };
207
209
  }
208
210
  catch (e) {
211
+ this.healthTracker?.recordFailure(e);
209
212
  const msg = e instanceof Error ? e.message : String(e);
210
213
  return { created: false, reason: `create_failed: ${msg}` };
211
214
  }
@@ -336,8 +339,11 @@ ${[...new Set(proposal.toolsUsed)].map((t) => `- ${t}`).join('\n')}
336
339
  return this.qualityCache;
337
340
  }
338
341
  }
339
- catch {
340
- // ignore corrupt file is replaced on next save.
342
+ catch (e) {
343
+ // Phase v4.1.2-slice3: record corrupt-file / JSON parse / read
344
+ // failures into the health tracker. We still fall through to an
345
+ // empty cache so behavior is unchanged — telemetry is additive.
346
+ this.healthTracker?.recordFailure(e);
341
347
  }
342
348
  this.qualityCache = {};
343
349
  return this.qualityCache;
@@ -346,8 +352,11 @@ ${[...new Set(proposal.toolsUsed)].map((t) => `- ${t}`).join('\n')}
346
352
  try {
347
353
  await node_fs_1.promises.writeFile(this.qualityPath, JSON.stringify(cache, null, 2), 'utf-8');
348
354
  }
349
- catch {
350
- // ignore disk failures — quality data is best-effort.
355
+ catch (e) {
356
+ // Phase v4.1.2-slice3: surface disk-write failures (EACCES,
357
+ // ENOSPC, EROFS) instead of silently dropping. Best-effort
358
+ // semantic preserved — we still return without throwing.
359
+ this.healthTracker?.recordFailure(e);
351
360
  }
352
361
  }
353
362
  }
@@ -68,6 +68,7 @@ class ChatCompletionsAdapter {
68
68
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
69
69
  this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
70
70
  this.extraHeaders = opts.extraHeaders ?? {};
71
+ this.defaultExtraBody = opts.defaultExtraBody;
71
72
  }
72
73
  // ── Non-streaming ────────────────────────────────────────────────────
73
74
  async call(input) {
@@ -127,6 +128,14 @@ class ChatCompletionsAdapter {
127
128
  // the stream closes promptly and we get accurate token accounting.
128
129
  body.stream_options = { include_usage: true };
129
130
  }
131
+ // Phase v4.1.2-deepseek: merge order is base body → defaultExtraBody
132
+ // (model-mandated, from resolver lookup in MODEL_DEFAULTS) → per-call
133
+ // input.extraBody (caller). Per-call wins so a single request can
134
+ // override a default (e.g. disabling thinking on a probe). This
135
+ // matters because providers/v4/modelDefaults.ts sets thinking +
136
+ // reasoning_effort for deepseek-v4-pro on EVERY call.
137
+ if (this.defaultExtraBody)
138
+ Object.assign(body, this.defaultExtraBody);
130
139
  if (input.extraBody)
131
140
  Object.assign(body, input.extraBody);
132
141
  return body;
@@ -17,12 +17,21 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.ProviderRateLimitError = exports.ProviderTimeoutError = exports.ProviderError = void 0;
19
19
  exports.formatRawForMessage = formatRawForMessage;
20
+ exports.classifyProviderError = classifyProviderError;
21
+ exports.suggestForErrorClass = suggestForErrorClass;
20
22
  /**
21
23
  * Format a raw response body for inclusion in the user-facing error
22
- * message. Recognises the OpenAI / Anthropic JSON envelope shape
23
- * (`{ error: { message: "..." } }`) and falls back to the raw string
24
- * for plain-text bodies. Returns null when nothing useful is available
25
- * so callers can omit the ": <detail>" tail entirely.
24
+ * message. Recognises three JSON envelope shapes and falls back to the
25
+ * raw string for plain-text bodies. Returns null when nothing useful is
26
+ * available so callers can omit the ": <detail>" tail entirely.
27
+ *
28
+ * Recognised envelopes (most-specific first):
29
+ * 1. OpenAI / Anthropic: `{ error: { message: "..." } }`
30
+ * 2. Top-level message: `{ message: "..." }`
31
+ * 3. Codex Responses: `{ detail: "..." }` (Phase v4.1.2-bug3 —
32
+ * surfaced by slice5: the Codex backend at chatgpt.com/backend-api/
33
+ * codex/responses returns 4xx bodies in this shape, e.g.
34
+ * `{"detail": "The 'gpt-5.1-codex-max' model is not supported..."}`)
26
35
  *
27
36
  * Truncates to 300 chars to keep multi-line responses from blowing
28
37
  * up the user's terminal — full body remains on `error.raw` for
@@ -45,6 +54,15 @@ function formatRawForMessage(raw) {
45
54
  if (typeof topMsg === 'string' && topMsg.length > 0) {
46
55
  return topMsg.length > 300 ? `${topMsg.slice(0, 300)}…` : topMsg;
47
56
  }
57
+ // Codex Responses envelope: { detail: "..." }. Distinct from the
58
+ // OpenAI shape — the Codex backend uses FastAPI-style validation
59
+ // errors that surface as `detail` (str) for tier/auth rejections
60
+ // and `detail: [{...}]` for schema errors. Only the string form is
61
+ // useful in the message tail; the array form is left to .raw.
62
+ const detail = raw.detail;
63
+ if (typeof detail === 'string' && detail.length > 0) {
64
+ return detail.length > 300 ? `${detail.slice(0, 300)}…` : detail;
65
+ }
48
66
  return null;
49
67
  }
50
68
  // Plain string body.
@@ -93,3 +111,93 @@ class ProviderRateLimitError extends ProviderError {
93
111
  }
94
112
  }
95
113
  exports.ProviderRateLimitError = ProviderRateLimitError;
114
+ function classifyProviderError(err) {
115
+ if (err == null)
116
+ return 'other';
117
+ // 1. Type-based class detection (fastest, most structured).
118
+ if (err instanceof ProviderRateLimitError)
119
+ return 'rate_limit';
120
+ if (err instanceof ProviderTimeoutError)
121
+ return 'transport';
122
+ if (err instanceof ProviderError) {
123
+ if (err.statusCode === 413)
124
+ return 'context_overflow';
125
+ if (err.statusCode === 429)
126
+ return 'rate_limit';
127
+ if (err.statusCode === 401 || err.statusCode === 403)
128
+ return 'auth';
129
+ }
130
+ // 2. Fall back to message scanning. Adapters that pass through the
131
+ // upstream JSON `error.message` verbatim land here.
132
+ const msg = err instanceof Error ? err.message : String(err);
133
+ const lc = msg.toLowerCase();
134
+ // Context overflow / 413 family. Groq's free-tier TPM cap triggers
135
+ // these on the first turn once the prompt + tool schemas inflate.
136
+ if (lc.includes('413') ||
137
+ lc.includes('context_length_exceeded') ||
138
+ lc.includes('context length') ||
139
+ lc.includes('too large') ||
140
+ lc.includes('maximum context length') ||
141
+ lc.includes('payload too large')) {
142
+ return 'context_overflow';
143
+ }
144
+ // Rate-limit family — 429 / TPM / quota / "too many requests".
145
+ if (lc.includes('429') ||
146
+ lc.includes('rate_limit') ||
147
+ lc.includes('rate limit') ||
148
+ lc.includes('too many requests') ||
149
+ lc.includes('quota') ||
150
+ lc.includes('tpm')) {
151
+ return 'rate_limit';
152
+ }
153
+ // Auth family — 401 / 403 / invalid keys / unauthenticated.
154
+ if (lc.includes('401') ||
155
+ lc.includes('403') ||
156
+ lc.includes('invalid_api_key') ||
157
+ lc.includes('invalid api key') ||
158
+ lc.includes('unauthenticated') ||
159
+ lc.includes('unauthorized') ||
160
+ lc.includes('forbidden')) {
161
+ return 'auth';
162
+ }
163
+ // Transport — network, DNS, timeouts that escaped the typed path.
164
+ if (lc.includes('econnrefused') ||
165
+ lc.includes('enotfound') ||
166
+ lc.includes('etimedout') ||
167
+ lc.includes('socket hang up') ||
168
+ lc.includes('network')) {
169
+ return 'transport';
170
+ }
171
+ return 'other';
172
+ }
173
+ /**
174
+ * v4.1.3-prebump: produce a single-sentence actionable hint for the
175
+ * given error class. Returns null for `'other'` so the caller can keep
176
+ * its existing default suggestion. Provider name is surfaced where it
177
+ * sharpens the advice ("groq rate-limited" reads clearer than
178
+ * "rate limit").
179
+ *
180
+ * Pure helper. The REPL displays the result; tests assert it. No
181
+ * registry / state access — feed it the class + provider name.
182
+ */
183
+ function suggestForErrorClass(cls, providerName) {
184
+ const p = providerName ?? 'this provider';
185
+ switch (cls) {
186
+ case 'context_overflow':
187
+ return (`${p} returned 413 (context too large). The combined system prompt ` +
188
+ `+ tool schemas exceed ${p}'s context window. Try \`/model\` to ` +
189
+ `switch to a provider with more headroom (chatgpt-plus, anthropic, ` +
190
+ `deepseek).`);
191
+ case 'rate_limit':
192
+ return (`${p} is rate-limited. Wait a minute, or run \`/model\` to switch ` +
193
+ `to another authed provider while ${p} cools off.`);
194
+ case 'auth':
195
+ return (`${p} rejected the credentials. Run \`/auth status\` (or check the ` +
196
+ `relevant API key env var) and \`/auth login\` if needed.`);
197
+ case 'transport':
198
+ return (`Network or transport error reaching ${p}. Check connectivity, then ` +
199
+ `retry — or \`/model\` to a local provider (ollama) for offline work.`);
200
+ case 'other':
201
+ return null;
202
+ }
203
+ }
@@ -0,0 +1,65 @@
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
+ * providers/v4/modelDefaults.ts — Phase v4.1.2-deepseek.
10
+ *
11
+ * Per-model default request parameters. Keyed by `${providerId}:${modelId}`
12
+ * so the same model slug appearing under multiple providers doesn't
13
+ * collide (e.g. a future shared-slug case across compat endpoints).
14
+ *
15
+ * Today's only consumer:
16
+ * deepseek:deepseek-v4-pro → always send extra_body.thinking +
17
+ * reasoning_effort, per DeepSeek's V4-Pro API guidance:
18
+ *
19
+ * client.chat.completions.create(
20
+ * model="deepseek-v4-pro",
21
+ * messages=...,
22
+ * reasoning_effort="high",
23
+ * extra_body={"thinking": {"type": "enabled"}},
24
+ * )
25
+ *
26
+ * The defaults are merged into the wire body by ChatCompletionsAdapter:
27
+ * base body → defaultExtraBody (from this map) → per-call extraBody
28
+ * (from the caller's ProviderCallInput)
29
+ *
30
+ * Per-call extraBody wins so a caller can disable thinking on a single
31
+ * request without un-registering the model default.
32
+ *
33
+ * Adding a new entry: keep this file small. Per-model knowledge lives
34
+ * here so resolver / registry / adapter stay pure (credential
35
+ * resolution / provider facts / wire-format mechanics respectively).
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.MODEL_DEFAULTS = void 0;
39
+ exports.getModelDefaults = getModelDefaults;
40
+ /**
41
+ * Per-`${providerId}:${modelId}` defaults. `undefined` lookup means
42
+ * the model takes no special handling.
43
+ */
44
+ exports.MODEL_DEFAULTS = Object.freeze({
45
+ // DeepSeek V4 Pro — thinking-mode flagship.
46
+ // Reference: https://api-docs.deepseek.com/ (verified 2026-05).
47
+ // deepseek-v4-flash exists too but is not wired this slice; its
48
+ // legacy aliases (deepseek-chat = v4-flash non-think,
49
+ // deepseek-reasoner = v4-flash think) stay un-defaulted to preserve
50
+ // the existing pass-through behavior for users who explicitly
51
+ // selected them.
52
+ 'deepseek:deepseek-v4-pro': {
53
+ extraBody: {
54
+ thinking: { type: 'enabled' },
55
+ reasoning_effort: 'high',
56
+ },
57
+ },
58
+ });
59
+ /**
60
+ * Look up defaults for a (provider, model) pair. Returns `undefined`
61
+ * when no entry exists — caller skips the extraBody merge.
62
+ */
63
+ function getModelDefaults(providerId, modelId) {
64
+ return exports.MODEL_DEFAULTS[`${providerId}:${modelId}`];
65
+ }