aiden-runtime 4.1.1 → 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.
- package/README.md +78 -26
- package/dist/cli/v4/aidenCLI.js +159 -9
- package/dist/cli/v4/callbacks.js +5 -2
- package/dist/cli/v4/chatSession.js +525 -15
- package/dist/cli/v4/commands/auth.js +6 -3
- package/dist/cli/v4/commands/help.js +4 -0
- package/dist/cli/v4/commands/index.js +10 -1
- package/dist/cli/v4/commands/reloadSoul.js +37 -0
- package/dist/cli/v4/commands/update.js +102 -0
- package/dist/cli/v4/defaultSoul.js +68 -2
- package/dist/cli/v4/display.js +28 -10
- package/dist/cli/v4/doctor.js +112 -0
- package/dist/cli/v4/doctorLiveness.js +65 -10
- package/dist/cli/v4/promotionPrompt.js +202 -0
- package/dist/cli/v4/providerBootSelector.js +144 -0
- package/dist/cli/v4/sessionSummaryGate.js +66 -0
- package/dist/cli/v4/toolPreview.js +139 -0
- package/dist/core/v4/aidenAgent.js +91 -29
- package/dist/core/v4/capabilities.js +89 -0
- package/dist/core/v4/contextCompressor.js +25 -8
- package/dist/core/v4/distillationIndex.js +167 -0
- package/dist/core/v4/distillationStore.js +98 -0
- package/dist/core/v4/logger/logger.js +40 -9
- package/dist/core/v4/promotionCandidates.js +234 -0
- package/dist/core/v4/promptBuilder.js +145 -1
- package/dist/core/v4/sessionDistiller.js +405 -0
- package/dist/core/v4/skillMining/skillMiner.js +43 -6
- package/dist/core/v4/skillOutcomeTracker.js +323 -0
- package/dist/core/v4/subsystemHealth.js +143 -0
- package/dist/core/v4/update/executeInstall.js +233 -0
- package/dist/core/version.js +1 -1
- package/dist/moat/memoryGuard.js +111 -0
- package/dist/moat/skillTeacher.js +14 -5
- package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
- package/dist/providers/v4/errors.js +20 -4
- package/dist/providers/v4/modelDefaults.js +65 -0
- package/dist/providers/v4/registry.js +9 -2
- package/dist/providers/v4/runtimeResolver.js +6 -0
- package/dist/tools/v4/index.js +57 -1
- package/dist/tools/v4/memory/memoryRemove.js +57 -2
- package/dist/tools/v4/memory/sessionSummary.js +151 -0
- package/dist/tools/v4/sessions/recallSession.js +163 -0
- package/dist/tools/v4/sessions/sessionSearch.js +5 -1
- package/dist/tools/v4/system/_psHelpers.js +55 -0
- package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
- package/dist/tools/v4/system/appClose.js +79 -0
- package/dist/tools/v4/system/appLaunch.js +92 -0
- package/dist/tools/v4/system/clipboardRead.js +54 -0
- package/dist/tools/v4/system/clipboardWrite.js +84 -0
- package/dist/tools/v4/system/mediaKey.js +78 -0
- package/dist/tools/v4/system/osProcessList.js +99 -0
- package/dist/tools/v4/system/screenshot.js +106 -0
- package/dist/tools/v4/system/volumeSet.js +157 -0
- package/package.json +4 -1
- package/skills/system_control.md +135 -69
|
@@ -0,0 +1,323 @@
|
|
|
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/skillOutcomeTracker.ts — Phase v4.1.2-slice4.
|
|
10
|
+
*
|
|
11
|
+
* Track whether skills actually succeed when loaded. The mining-time
|
|
12
|
+
* confidence score (skillMining/skillMiner.ts:computeConfidence) is
|
|
13
|
+
* set once and never updated — skills that consistently produce bad
|
|
14
|
+
* tool-call traces stay confident; skills that consistently work well
|
|
15
|
+
* never accumulate evidence of that.
|
|
16
|
+
*
|
|
17
|
+
* Mechanism:
|
|
18
|
+
* - When `skill_view` fires (the model just received a skill body),
|
|
19
|
+
* open an attribution WINDOW for that skill: the next N tool calls
|
|
20
|
+
* are attributed as that skill's downstream outcomes.
|
|
21
|
+
* - Tool successes / failures attributed to the skill (counter-bump).
|
|
22
|
+
* - Another `skill_view` supersedes the window (last-write-wins).
|
|
23
|
+
* - Window closes after N tool calls or when superseded.
|
|
24
|
+
*
|
|
25
|
+
* What this is NOT:
|
|
26
|
+
* - Not a quality judge. We don't ask an LLM "did that skill help?".
|
|
27
|
+
* Tool success is a proxy — a noisy one — but it's deterministic
|
|
28
|
+
* and free. Per slice4 Phase 3 decision tree: Option A.
|
|
29
|
+
* - Not a promotion engine. Surfaced via `aiden doctor`; the existing
|
|
30
|
+
* SkillTeacher.flaggedSkillNames() flagging path stays dead (it
|
|
31
|
+
* would change SkillLoader behavior — separate decision).
|
|
32
|
+
*
|
|
33
|
+
* Persistence:
|
|
34
|
+
* `<skillsDir>/.skill-outcomes.json` — sidecar, atomic write
|
|
35
|
+
* (tmp + rename), best-effort failure handling via slice3
|
|
36
|
+
* SubsystemHealthTracker. Lazy hydrate on first `onTool` call so
|
|
37
|
+
* sessions that never load a skill pay zero disk I/O.
|
|
38
|
+
*
|
|
39
|
+
* Status: PHASE v4.1.2-slice4.
|
|
40
|
+
*/
|
|
41
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
42
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.SkillOutcomeTracker = exports.ATTRIBUTION_WINDOW = void 0;
|
|
46
|
+
exports.isFailure = isFailure;
|
|
47
|
+
const node_fs_1 = require("node:fs");
|
|
48
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
49
|
+
/**
|
|
50
|
+
* Attribution window size — number of non-skill_view tool calls
|
|
51
|
+
* following a `skill_view` whose outcomes are attributed to that
|
|
52
|
+
* skill. Hard-coded per slice4 Phase 3 Q1: don't add config knobs
|
|
53
|
+
* we won't tune. If empirical signal shows 5 is wrong, change it
|
|
54
|
+
* here.
|
|
55
|
+
*/
|
|
56
|
+
exports.ATTRIBUTION_WINDOW = 5;
|
|
57
|
+
/** Cap for `lastError.message` — keep snapshots small. */
|
|
58
|
+
const ERROR_MESSAGE_CAP = 200;
|
|
59
|
+
class SkillOutcomeTracker {
|
|
60
|
+
/**
|
|
61
|
+
* @param persistPath Absolute path to the sidecar JSON file.
|
|
62
|
+
* @param healthTracker Optional slice3 tracker for persist failures.
|
|
63
|
+
*/
|
|
64
|
+
constructor(persistPath, healthTracker) {
|
|
65
|
+
this.persistPath = persistPath;
|
|
66
|
+
this.healthTracker = healthTracker;
|
|
67
|
+
/** Currently-loaded skill (last skill_view, while its window is open). */
|
|
68
|
+
this.currentSkill = null;
|
|
69
|
+
/** Tool calls remaining in the current attribution window. */
|
|
70
|
+
this.remaining = 0;
|
|
71
|
+
/** In-memory outcomes, keyed by skill name. Hydrated lazily. */
|
|
72
|
+
this.outcomes = new Map();
|
|
73
|
+
/** True once we've attempted hydration from disk. */
|
|
74
|
+
this.hydrated = false;
|
|
75
|
+
/** Pending persist requested while one is in flight. */
|
|
76
|
+
this.persistQueued = false;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Unified hook compatible with `AidenAgentOptions.onToolCall`.
|
|
80
|
+
* The agent fires it as `(call, 'before')` then `(call, 'after', result)`.
|
|
81
|
+
*/
|
|
82
|
+
onTool(call, phase, result) {
|
|
83
|
+
if (phase === 'before')
|
|
84
|
+
this.onToolBefore(call);
|
|
85
|
+
else
|
|
86
|
+
this.onToolAfter(call, result);
|
|
87
|
+
}
|
|
88
|
+
/** Called before each tool. Opens / supersedes the attribution window. */
|
|
89
|
+
onToolBefore(call) {
|
|
90
|
+
if (call.name !== 'skill_view')
|
|
91
|
+
return;
|
|
92
|
+
const name = extractSkillName(call.arguments);
|
|
93
|
+
if (!name)
|
|
94
|
+
return;
|
|
95
|
+
// Hydrate synchronously so the bump below merges with any prior
|
|
96
|
+
// persisted state. The file is small (one row per ever-loaded
|
|
97
|
+
// skill), so the one-time sync read is cheap and avoids the
|
|
98
|
+
// ordering hazard of awaiting in an inherently sync hook.
|
|
99
|
+
this.ensureHydratedSync();
|
|
100
|
+
this.currentSkill = name;
|
|
101
|
+
this.remaining = exports.ATTRIBUTION_WINDOW;
|
|
102
|
+
this.bump(name, (o) => {
|
|
103
|
+
o.loaded += 1;
|
|
104
|
+
o.lastUsed = new Date().toISOString();
|
|
105
|
+
});
|
|
106
|
+
void this.queuePersist();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Called after each tool. Attributes success/failure to the currently
|
|
110
|
+
* open window. `skill_view` itself does NOT attribute back to itself
|
|
111
|
+
* (the window's purpose is to grade DOWNSTREAM tools).
|
|
112
|
+
*/
|
|
113
|
+
onToolAfter(call, result) {
|
|
114
|
+
if (call.name === 'skill_view')
|
|
115
|
+
return;
|
|
116
|
+
if (!this.currentSkill || this.remaining <= 0)
|
|
117
|
+
return;
|
|
118
|
+
const skill = this.currentSkill;
|
|
119
|
+
const failed = isFailure(result);
|
|
120
|
+
this.bump(skill, (o) => {
|
|
121
|
+
if (failed) {
|
|
122
|
+
o.toolFailures += 1;
|
|
123
|
+
const msg = extractErrorMessage(result);
|
|
124
|
+
if (msg) {
|
|
125
|
+
o.lastError = {
|
|
126
|
+
message: truncate(msg, ERROR_MESSAGE_CAP),
|
|
127
|
+
at: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
o.toolSuccesses += 1;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
this.remaining -= 1;
|
|
136
|
+
if (this.remaining === 0)
|
|
137
|
+
this.currentSkill = null;
|
|
138
|
+
void this.queuePersist();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read-only snapshot for `aiden doctor`. Sorted by `loaded` descending
|
|
142
|
+
* so the most-used skills surface first.
|
|
143
|
+
*/
|
|
144
|
+
snapshot() {
|
|
145
|
+
const arr = Array.from(this.outcomes.values());
|
|
146
|
+
arr.sort((a, b) => b.loaded - a.loaded);
|
|
147
|
+
return arr;
|
|
148
|
+
}
|
|
149
|
+
/** Total skills with at least one observation. */
|
|
150
|
+
size() {
|
|
151
|
+
return this.outcomes.size;
|
|
152
|
+
}
|
|
153
|
+
// ── private ───────────────────────────────────────────────────────
|
|
154
|
+
bump(skillName, mutator) {
|
|
155
|
+
const cur = this.outcomes.get(skillName) ?? {
|
|
156
|
+
skillName,
|
|
157
|
+
loaded: 0,
|
|
158
|
+
toolSuccesses: 0,
|
|
159
|
+
toolFailures: 0,
|
|
160
|
+
};
|
|
161
|
+
mutator(cur);
|
|
162
|
+
this.outcomes.set(skillName, cur);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Synchronous disk-hydration. Called once per instance lifetime on
|
|
166
|
+
* the first `skill_view` observation. The sidecar is small (one row
|
|
167
|
+
* per ever-loaded skill) so a sync read is cheap and removes the
|
|
168
|
+
* race between async hydration and immediately-following bumps.
|
|
169
|
+
*
|
|
170
|
+
* Failures (parse, EACCES) get recorded into the health tracker —
|
|
171
|
+
* doctor surfaces them. ENOENT (no file yet) is the common case on
|
|
172
|
+
* first run and stays silent.
|
|
173
|
+
*/
|
|
174
|
+
ensureHydratedSync() {
|
|
175
|
+
if (this.hydrated)
|
|
176
|
+
return;
|
|
177
|
+
this.hydrated = true;
|
|
178
|
+
try {
|
|
179
|
+
if (!(0, node_fs_1.existsSync)(this.persistPath))
|
|
180
|
+
return;
|
|
181
|
+
const raw = (0, node_fs_1.readFileSync)(this.persistPath, 'utf-8');
|
|
182
|
+
const parsed = JSON.parse(raw);
|
|
183
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
184
|
+
for (const [name, val] of Object.entries(parsed)) {
|
|
185
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
186
|
+
const v = val;
|
|
187
|
+
this.outcomes.set(name, {
|
|
188
|
+
skillName: v.skillName ?? name,
|
|
189
|
+
loaded: Number(v.loaded ?? 0),
|
|
190
|
+
toolSuccesses: Number(v.toolSuccesses ?? 0),
|
|
191
|
+
toolFailures: Number(v.toolFailures ?? 0),
|
|
192
|
+
...(v.lastUsed ? { lastUsed: v.lastUsed } : {}),
|
|
193
|
+
...(v.lastError ? { lastError: v.lastError } : {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
this.healthTracker?.recordFailure(err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Test/shutdown seam. Awaits any in-flight or queued persist so the
|
|
205
|
+
* caller knows the sidecar is on disk. The agent runtime doesn't
|
|
206
|
+
* need to call this (writes are durable enough via the coalescing
|
|
207
|
+
* queue); tests use it to deterministically wait for I/O.
|
|
208
|
+
*/
|
|
209
|
+
async flush() {
|
|
210
|
+
while (this.persisting) {
|
|
211
|
+
await this.persisting;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Coalescing persist. If a write is in flight, queue exactly one
|
|
216
|
+
* follow-up; further requests collapse into that single follow-up.
|
|
217
|
+
* Keeps disk I/O cheap when many tool calls happen in a burst.
|
|
218
|
+
*/
|
|
219
|
+
queuePersist() {
|
|
220
|
+
if (this.persisting) {
|
|
221
|
+
this.persistQueued = true;
|
|
222
|
+
return this.persisting;
|
|
223
|
+
}
|
|
224
|
+
this.persisting = this.persist()
|
|
225
|
+
.finally(() => {
|
|
226
|
+
const wasQueued = this.persistQueued;
|
|
227
|
+
this.persistQueued = false;
|
|
228
|
+
this.persisting = undefined;
|
|
229
|
+
if (wasQueued) {
|
|
230
|
+
// Fire-and-forget the queued follow-up.
|
|
231
|
+
void this.queuePersist();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return this.persisting;
|
|
235
|
+
}
|
|
236
|
+
async persist() {
|
|
237
|
+
try {
|
|
238
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.persistPath), { recursive: true });
|
|
239
|
+
const payload = {};
|
|
240
|
+
for (const [k, v] of this.outcomes)
|
|
241
|
+
payload[k] = v;
|
|
242
|
+
const tmp = `${this.persistPath}.tmp`;
|
|
243
|
+
await node_fs_1.promises.writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
244
|
+
await node_fs_1.promises.rename(tmp, this.persistPath);
|
|
245
|
+
this.healthTracker?.recordSuccess();
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
this.healthTracker?.recordFailure(err);
|
|
249
|
+
// Best-effort: clean up tmp file if it exists. Ignore errors.
|
|
250
|
+
try {
|
|
251
|
+
await node_fs_1.promises.unlink(`${this.persistPath}.tmp`);
|
|
252
|
+
}
|
|
253
|
+
catch { /* ignore */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
exports.SkillOutcomeTracker = SkillOutcomeTracker;
|
|
258
|
+
// ── private helpers ───────────────────────────────────────────────────
|
|
259
|
+
function extractSkillName(args) {
|
|
260
|
+
if (!args || typeof args !== 'object')
|
|
261
|
+
return '';
|
|
262
|
+
const v = args.name;
|
|
263
|
+
return typeof v === 'string' ? v.trim() : '';
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Failure classification rules (per slice4 Phase 3 explicit decision):
|
|
267
|
+
* - result.success === false → failure
|
|
268
|
+
* - result.error truthy → failure
|
|
269
|
+
* - everything else → success
|
|
270
|
+
*
|
|
271
|
+
* "Tool succeeded but result was wrong" is NOT classifiable without an
|
|
272
|
+
* LLM judge and is intentionally out of scope.
|
|
273
|
+
*/
|
|
274
|
+
function isFailure(result) {
|
|
275
|
+
if (!result)
|
|
276
|
+
return false;
|
|
277
|
+
// The ToolCallResult shape from providers/v4/types is { id, name, result }.
|
|
278
|
+
// Tool implementations conventionally return `{ success: boolean, error?, ... }`
|
|
279
|
+
// inside the `result` payload — both are surveyed.
|
|
280
|
+
const top = result;
|
|
281
|
+
if (top.error)
|
|
282
|
+
return true;
|
|
283
|
+
if (top.success === false)
|
|
284
|
+
return true;
|
|
285
|
+
const inner = result.result;
|
|
286
|
+
if (inner && typeof inner === 'object') {
|
|
287
|
+
const i = inner;
|
|
288
|
+
if (i.error)
|
|
289
|
+
return true;
|
|
290
|
+
if (i.success === false)
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function extractErrorMessage(result) {
|
|
296
|
+
if (!result)
|
|
297
|
+
return '';
|
|
298
|
+
const top = result;
|
|
299
|
+
if (typeof top.error === 'string')
|
|
300
|
+
return top.error;
|
|
301
|
+
if (top.error && typeof top.error === 'object') {
|
|
302
|
+
const m = top.error.message;
|
|
303
|
+
if (typeof m === 'string')
|
|
304
|
+
return m;
|
|
305
|
+
}
|
|
306
|
+
const inner = result.result;
|
|
307
|
+
if (inner && typeof inner === 'object') {
|
|
308
|
+
const i = inner;
|
|
309
|
+
if (typeof i.error === 'string')
|
|
310
|
+
return i.error;
|
|
311
|
+
if (i.error && typeof i.error === 'object') {
|
|
312
|
+
const m = i.error.message;
|
|
313
|
+
if (typeof m === 'string')
|
|
314
|
+
return m;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return '';
|
|
318
|
+
}
|
|
319
|
+
function truncate(s, max) {
|
|
320
|
+
if (s.length <= max)
|
|
321
|
+
return s;
|
|
322
|
+
return s.slice(0, max - 3) + '...';
|
|
323
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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/subsystemHealth.ts — Phase v4.1.2-slice3.
|
|
10
|
+
*
|
|
11
|
+
* Lightweight in-process telemetry for the silent-failure layers.
|
|
12
|
+
* Four subsystems (ContextCompressor, SkillTeacher, SkillMiner,
|
|
13
|
+
* Logger) historically caught errors and continued without
|
|
14
|
+
* surfacing them — masking real bugs that were diagnosable only
|
|
15
|
+
* after manual instrumentation. This module is the surface.
|
|
16
|
+
*
|
|
17
|
+
* Design (decision tree from slice3 Phase 3):
|
|
18
|
+
* Option C — subsystem-owned state object, optionally registered
|
|
19
|
+
* with a shared registry. The registry is constructor-injected
|
|
20
|
+
* (no singleton — singletons leak state between parallel tests),
|
|
21
|
+
* and every record op is O(1) and side-effect-free (no I/O, no
|
|
22
|
+
* log writes, no recursion through the Logger we are tracking).
|
|
23
|
+
*
|
|
24
|
+
* Surface:
|
|
25
|
+
* - `SubsystemHealth` — read-only snapshot shape doctor renders
|
|
26
|
+
* - `SubsystemHealthTracker` — per-subsystem owned counter
|
|
27
|
+
* - `SubsystemHealthRegistry`— optional aggregator AidenAgent owns
|
|
28
|
+
*
|
|
29
|
+
* Subsystems may operate without a tracker (back-compat); when a
|
|
30
|
+
* tracker is wired they call `recordSuccess()` / `recordFailure(err)`
|
|
31
|
+
* at the appropriate points. The registry is read by `aiden doctor`
|
|
32
|
+
* via the AidenAgent public field.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.SubsystemHealthTracker = void 0;
|
|
36
|
+
exports.createSubsystemHealthRegistry = createSubsystemHealthRegistry;
|
|
37
|
+
/**
|
|
38
|
+
* Per-subsystem health counter. One instance per subsystem; cheap
|
|
39
|
+
* to construct (no I/O, no allocations beyond the counter object).
|
|
40
|
+
*
|
|
41
|
+
* Subsystems hold a private tracker (or undefined for back-compat)
|
|
42
|
+
* and call `recordSuccess()` / `recordFailure(err)` from their
|
|
43
|
+
* critical paths. The tracker is registered with the registry at
|
|
44
|
+
* construction; doctor reads the snapshot lazily.
|
|
45
|
+
*/
|
|
46
|
+
class SubsystemHealthTracker {
|
|
47
|
+
/**
|
|
48
|
+
* @param subsystem Stable id rendered by doctor. Prefer kebab-case
|
|
49
|
+
* ('compressor', 'skill-teacher', 'logger:file-sink').
|
|
50
|
+
*/
|
|
51
|
+
constructor(subsystem) {
|
|
52
|
+
this.subsystem = subsystem;
|
|
53
|
+
this._totalCalls = 0;
|
|
54
|
+
this._totalErrors = 0;
|
|
55
|
+
this._consecutive = 0;
|
|
56
|
+
}
|
|
57
|
+
/** O(1): bump call counter, reset consecutive-failure streak. */
|
|
58
|
+
recordSuccess() {
|
|
59
|
+
this._totalCalls += 1;
|
|
60
|
+
this._consecutive = 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* O(1): bump call + error counters, update lastError with a
|
|
64
|
+
* length-capped message. Never logs (would recurse through the
|
|
65
|
+
* Logger we are tracking) and never writes to disk.
|
|
66
|
+
*/
|
|
67
|
+
recordFailure(err) {
|
|
68
|
+
this._totalCalls += 1;
|
|
69
|
+
this._totalErrors += 1;
|
|
70
|
+
this._consecutive += 1;
|
|
71
|
+
const raw = err instanceof Error ? err.message
|
|
72
|
+
: typeof err === 'string' ? err
|
|
73
|
+
: safeStringify(err);
|
|
74
|
+
this._lastError = {
|
|
75
|
+
message: truncate(raw, 200),
|
|
76
|
+
at: new Date(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Render the current state. Doctor invokes this on demand. */
|
|
80
|
+
snapshot() {
|
|
81
|
+
const snap = {
|
|
82
|
+
subsystem: this.subsystem,
|
|
83
|
+
totalCalls: this._totalCalls,
|
|
84
|
+
totalErrors: this._totalErrors,
|
|
85
|
+
};
|
|
86
|
+
if (this._lastError) {
|
|
87
|
+
snap.lastError = {
|
|
88
|
+
message: this._lastError.message,
|
|
89
|
+
at: this._lastError.at,
|
|
90
|
+
consecutive: this._consecutive,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return snap;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.SubsystemHealthTracker = SubsystemHealthTracker;
|
|
97
|
+
/** Build a fresh registry. No I/O; cheap. */
|
|
98
|
+
function createSubsystemHealthRegistry() {
|
|
99
|
+
const readers = new Map();
|
|
100
|
+
return {
|
|
101
|
+
register(subsystem, reader) {
|
|
102
|
+
readers.set(subsystem, reader);
|
|
103
|
+
},
|
|
104
|
+
snapshot() {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const reader of readers.values()) {
|
|
107
|
+
try {
|
|
108
|
+
const v = reader();
|
|
109
|
+
if (Array.isArray(v))
|
|
110
|
+
out.push(...v);
|
|
111
|
+
else
|
|
112
|
+
out.push(v);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Reader threw — skip it. Telemetry must never break doctor.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
},
|
|
120
|
+
reset() {
|
|
121
|
+
readers.clear();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// ── private helpers ───────────────────────────────────────────────────
|
|
126
|
+
function truncate(s, max) {
|
|
127
|
+
if (s.length <= max)
|
|
128
|
+
return s;
|
|
129
|
+
return s.slice(0, max - 3) + '...';
|
|
130
|
+
}
|
|
131
|
+
function safeStringify(v) {
|
|
132
|
+
// `JSON.stringify(undefined)` returns the value `undefined`, not the
|
|
133
|
+
// string "undefined" — guard so the downstream length-cap doesn't
|
|
134
|
+
// crash. Symbols, functions, and circular objects also need a
|
|
135
|
+
// String() fallback.
|
|
136
|
+
try {
|
|
137
|
+
const out = JSON.stringify(v);
|
|
138
|
+
return typeof out === 'string' ? out : String(v);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return String(v);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|