aiden-runtime 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/cli/v4/aidenCLI.js +185 -99
- package/dist/cli/v4/chatSession.js +107 -0
- package/dist/cli/v4/commands/_runtimeToggleHelpers.js +2 -0
- package/dist/cli/v4/commands/fanout.js +42 -59
- package/dist/cli/v4/commands/help.js +6 -0
- package/dist/cli/v4/commands/index.js +16 -1
- package/dist/cli/v4/commands/mcp.js +80 -54
- package/dist/cli/v4/commands/plannerGuard.js +53 -0
- package/dist/cli/v4/commands/recovery.js +122 -0
- package/dist/cli/v4/commands/runs.js +22 -2
- package/dist/cli/v4/commands/spawnPause.js +93 -0
- package/dist/cli/v4/daemonAgentBuilder.js +4 -1
- package/dist/cli/v4/defaultSoul.js +1 -1
- package/dist/core/v4/aidenAgent.js +219 -1
- package/dist/core/v4/daemon/bootstrap.js +47 -0
- package/dist/core/v4/daemon/db/migrations.js +66 -0
- package/dist/core/v4/daemon/runStore.js +33 -3
- package/dist/core/v4/providerFallback.js +35 -2
- package/dist/core/v4/runtimeToggles.js +30 -3
- package/dist/core/v4/selfimprovement/recoveryStore.js +307 -0
- package/dist/core/v4/selfimprovement/signatureBuilder.js +158 -0
- package/dist/core/v4/subagent/childBuilder.js +391 -0
- package/dist/core/v4/subagent/fanout.js +75 -51
- package/dist/core/v4/subagent/spawnPause.js +191 -0
- package/dist/core/v4/subagent/spawnSubAgent.js +310 -0
- package/dist/core/v4/toolRegistry.js +19 -3
- package/dist/core/version.js +1 -1
- package/dist/moat/plannerGuard.js +29 -0
- package/dist/providers/v4/anthropicAdapter.js +31 -3
- package/dist/providers/v4/chatCompletionsAdapter.js +26 -3
- package/dist/providers/v4/codexResponsesAdapter.js +25 -2
- package/dist/providers/v4/ollamaPromptToolsAdapter.js +57 -2
- package/dist/tools/v4/index.js +17 -3
- package/dist/tools/v4/skills/lookupToolSchema.js +6 -1
- package/dist/tools/v4/subagent/spawnSubAgentTool.js +334 -0
- package/dist/tools/v4/subagent/subagentFanout.js +53 -1
- package/package.json +7 -3
|
@@ -427,16 +427,49 @@ class FallbackAdapter {
|
|
|
427
427
|
* provider key, but isolation prevents one slow subagent from
|
|
428
428
|
* starving siblings via parent-side cooldown state.
|
|
429
429
|
*/
|
|
430
|
-
clone() {
|
|
430
|
+
clone(opts) {
|
|
431
|
+
// v4.6 Phase 2P — optional `providerId` filter restricts the clone
|
|
432
|
+
// to slots matching the named provider. Used by `spawn_sub_agent`'s
|
|
433
|
+
// per-spawn provider override: fanout (and future callers) pass a
|
|
434
|
+
// specific provider name so the child's FallbackAdapter rotates
|
|
435
|
+
// only within that provider's slots, preserving the diversity
|
|
436
|
+
// invariant fanout depends on. When omitted, full-slot clone is
|
|
437
|
+
// the Phase 1 behaviour (unchanged).
|
|
438
|
+
//
|
|
439
|
+
// Caller is responsible for validating `providerId` against
|
|
440
|
+
// `getProviderIds()` before calling — an unknown providerId
|
|
441
|
+
// here yields a degenerate clone (zero slots), which the
|
|
442
|
+
// FallbackAdapter's own dispatch path treats as "no providers"
|
|
443
|
+
// and would error on first call. Defending here would mask the
|
|
444
|
+
// upstream validation gap; we prefer fail-loud at the validation
|
|
445
|
+
// layer instead.
|
|
446
|
+
const slots = opts?.providerId
|
|
447
|
+
? this.slots.filter((s) => s.providerId === opts.providerId)
|
|
448
|
+
: this.slots;
|
|
431
449
|
return new FallbackAdapter({
|
|
432
450
|
apiMode: this.apiMode,
|
|
433
|
-
slots
|
|
451
|
+
slots,
|
|
434
452
|
cooldownMs: this.cooldownMs,
|
|
435
453
|
now: this.clock,
|
|
436
454
|
onRateLimit: this.onRateLimit,
|
|
437
455
|
onFallback: this.onFallback,
|
|
438
456
|
});
|
|
439
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* v4.6 Phase 2P — return the set of provider IDs the adapter knows
|
|
460
|
+
* about (deduplicated, key-present slots only). Used by
|
|
461
|
+
* `spawn_sub_agent`'s per-spawn provider override validation: the
|
|
462
|
+
* spec's `provider` field must name one of these. Sorted for
|
|
463
|
+
* stable diagnostic output in error messages.
|
|
464
|
+
*/
|
|
465
|
+
getProviderIds() {
|
|
466
|
+
const seen = new Set();
|
|
467
|
+
for (const slot of this.slots) {
|
|
468
|
+
if (slot.keyPresent)
|
|
469
|
+
seen.add(slot.providerId);
|
|
470
|
+
}
|
|
471
|
+
return [...seen].sort();
|
|
472
|
+
}
|
|
440
473
|
/**
|
|
441
474
|
* Diagnostic snapshot for `/providers`. Per-slot cooldown is reported
|
|
442
475
|
* in seconds remaining (0 when the slot is fresh) so the slash command
|
|
@@ -52,14 +52,41 @@ const ENV_VAR = {
|
|
|
52
52
|
// env (this is mostly a UX toggle) but included for symmetry with
|
|
53
53
|
// the other subsystem toggles.
|
|
54
54
|
suggestions: 'AIDEN_SUGGESTIONS',
|
|
55
|
+
// v4.6 Phase 2M — keyword-based per-turn tool narrowing.
|
|
56
|
+
// Default OFF: smart models (GPT-5.5, Claude Sonnet 4.5+, Opus)
|
|
57
|
+
// pick tools fine from a full catalog. PlannerGuard adds latency
|
|
58
|
+
// (1 LLM call when mode=llm_classified) and occasionally strips
|
|
59
|
+
// tools the model genuinely needed. Opt in for small local models
|
|
60
|
+
// that get overwhelmed by 50+ tool schemas.
|
|
61
|
+
planner_guard: 'AIDEN_PLANNER_GUARD',
|
|
55
62
|
};
|
|
56
63
|
const CONFIG_KEY = {
|
|
57
64
|
sandbox: 'runtime_toggles.sandbox',
|
|
58
65
|
tce: 'runtime_toggles.tce',
|
|
59
66
|
browser_depth: 'runtime_toggles.browser_depth',
|
|
60
67
|
suggestions: 'runtime_toggles.suggestions',
|
|
68
|
+
planner_guard: 'runtime_toggles.planner_guard',
|
|
69
|
+
};
|
|
70
|
+
const ALL_KEYS = [
|
|
71
|
+
'sandbox', 'tce', 'browser_depth', 'suggestions', 'planner_guard',
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* v4.6 Phase 2M — per-key default. Pre-2M every toggle defaulted to
|
|
75
|
+
* `true` (sandbox/tce/browser-depth/suggestions all ship on); the
|
|
76
|
+
* `planner_guard` toggle is the first to default `false`, so the
|
|
77
|
+
* resolver needs a per-key default map rather than a hardcoded `true`.
|
|
78
|
+
*
|
|
79
|
+
* Smart models (GPT-5.5, Claude Sonnet 4.5+, Opus) pick from the
|
|
80
|
+
* full tool catalog without help — keyword-based narrowing is a
|
|
81
|
+
* legacy workaround for smaller local models, opt in when needed.
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_VALUE = {
|
|
84
|
+
sandbox: true,
|
|
85
|
+
tce: true,
|
|
86
|
+
browser_depth: true,
|
|
87
|
+
suggestions: true,
|
|
88
|
+
planner_guard: false,
|
|
61
89
|
};
|
|
62
|
-
const ALL_KEYS = ['sandbox', 'tce', 'browser_depth', 'suggestions'];
|
|
63
90
|
// ── Resolver primitives ────────────────────────────────────────────────────
|
|
64
91
|
/**
|
|
65
92
|
* Strict env interpretation matching existing v4.2/v4.3/v4.4
|
|
@@ -122,8 +149,8 @@ function buildRuntimeToggles(deps = {}) {
|
|
|
122
149
|
const cfgValue = readConfig(deps.configRead, key);
|
|
123
150
|
if (cfgValue !== null)
|
|
124
151
|
return { value: cfgValue, source: 'config' };
|
|
125
|
-
// 4. default
|
|
126
|
-
return { value:
|
|
152
|
+
// 4. default (v4.6 Phase 2M — per-key, see DEFAULT_VALUE)
|
|
153
|
+
return { value: DEFAULT_VALUE[key], source: 'default' };
|
|
127
154
|
}
|
|
128
155
|
function fire(key) {
|
|
129
156
|
const set = subscribers.get(key);
|
|
@@ -0,0 +1,307 @@
|
|
|
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/selfimprovement/recoveryStore.ts — v4.6 Phase 3b.
|
|
10
|
+
*
|
|
11
|
+
* Durable cross-session failure ledger + recovery report writer.
|
|
12
|
+
* Backed by the v7 schema's `failure_signatures` + `recovery_reports`
|
|
13
|
+
* tables. The store is the single write path for both halves of the
|
|
14
|
+
* self-improvement loop:
|
|
15
|
+
*
|
|
16
|
+
* 1. `recordFailureOccurrence(...)` — called on every classified
|
|
17
|
+
* failure (TCE write-through at the aidenAgent classify site).
|
|
18
|
+
* Upserts the signature row, increments `occurrences`, updates
|
|
19
|
+
* `last_seen_at`.
|
|
20
|
+
*
|
|
21
|
+
* 2. `recordRecovery(...)` — called when a previously-failed
|
|
22
|
+
* signature is observed succeeding (or when the agent's TCE
|
|
23
|
+
* surfaces a structured recovery report at turn end).
|
|
24
|
+
* Inserts a `recovery_reports` row + bumps the signature's
|
|
25
|
+
* `recovered_count` + sets `last_recovery_report_id`.
|
|
26
|
+
*
|
|
27
|
+
* Reads:
|
|
28
|
+
*
|
|
29
|
+
* * `listTopFailures(limit)` — operator dashboard query.
|
|
30
|
+
* * `getBySignature(signature)` — `/recovery show` detail surface.
|
|
31
|
+
* * `listForSession(sessionId)` — used by future plugin hooks that
|
|
32
|
+
* want per-session summaries (currently unused; the operator
|
|
33
|
+
* command path goes via `listTopFailures` + `getBySignature`).
|
|
34
|
+
* * `listReportsForSignature(signatureId, limit)` — the recovery
|
|
35
|
+
* history for one signature.
|
|
36
|
+
*
|
|
37
|
+
* Singleton pattern mirrors `spawnPause` (Phase 3a): `initRecoveryStore({db})`
|
|
38
|
+
* at boot; `getRecoveryStore()` thereafter. Production wiring opens the
|
|
39
|
+
* daemon DB once at REPL/daemon/MCP boot and re-uses the singleton
|
|
40
|
+
* across REPL turns and daemon-fired turns. Tests reset via
|
|
41
|
+
* `_resetRecoveryStoreForTests()`.
|
|
42
|
+
*
|
|
43
|
+
* Failure mode: NEVER throws. A persistence failure (locked DB,
|
|
44
|
+
* schema drift, etc.) returns 0 / null and logs a warning. The
|
|
45
|
+
* TCE write-through path treats the store as best-effort — losing
|
|
46
|
+
* a signature increment does not break a turn.
|
|
47
|
+
*/
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.RecoveryStore = void 0;
|
|
50
|
+
exports.initRecoveryStore = initRecoveryStore;
|
|
51
|
+
exports.getRecoveryStore = getRecoveryStore;
|
|
52
|
+
exports._resetRecoveryStoreForTests = _resetRecoveryStoreForTests;
|
|
53
|
+
// ── Implementation ───────────────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* SQLite-backed store. Constructed with a `better-sqlite3` Database
|
|
56
|
+
* handle that already has the v7 migration applied. The store does
|
|
57
|
+
* NOT run migrations itself — that's the caller's job (typically
|
|
58
|
+
* `openDaemonDb` + `runMigrations`).
|
|
59
|
+
*/
|
|
60
|
+
class RecoveryStore {
|
|
61
|
+
constructor(db) {
|
|
62
|
+
this.db = db;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Upsert a failure signature + bump occurrences. Returns the
|
|
66
|
+
* signature row id, or 0 on persistence failure (logged). The
|
|
67
|
+
* caller (TCE write-through path) treats the return as best-effort.
|
|
68
|
+
*/
|
|
69
|
+
recordFailureOccurrence(opts) {
|
|
70
|
+
const now = opts.now ?? Date.now;
|
|
71
|
+
const ts = now();
|
|
72
|
+
try {
|
|
73
|
+
// SQLite-native UPSERT — single round trip per failure. The
|
|
74
|
+
// `excluded.x` syntax references the row we tried to insert.
|
|
75
|
+
const r = this.db.prepare(`
|
|
76
|
+
INSERT INTO failure_signatures
|
|
77
|
+
(signature, tool_name, failure_category, args_hash,
|
|
78
|
+
first_seen_at, last_seen_at, occurrences, recovered_count)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, 0)
|
|
80
|
+
ON CONFLICT(signature) DO UPDATE SET
|
|
81
|
+
last_seen_at = excluded.last_seen_at,
|
|
82
|
+
occurrences = failure_signatures.occurrences + 1
|
|
83
|
+
RETURNING id
|
|
84
|
+
`).get(opts.signature, opts.toolName, opts.category, opts.argsHash ?? null, ts, ts);
|
|
85
|
+
return r?.id ?? 0;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn('[recoveryStore] recordFailureOccurrence failed:', err instanceof Error ? err.message : String(err));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Record a successful recovery. Inserts a `recovery_reports`
|
|
95
|
+
* row + atomically bumps the signature's `recovered_count` and
|
|
96
|
+
* `last_recovery_report_id`. Returns the new report id, or 0 on
|
|
97
|
+
* failure.
|
|
98
|
+
*/
|
|
99
|
+
recordRecovery(opts) {
|
|
100
|
+
const now = opts.now ?? Date.now;
|
|
101
|
+
const ts = now();
|
|
102
|
+
try {
|
|
103
|
+
// Two-statement transaction — insert then update — keeps the
|
|
104
|
+
// signature's `last_recovery_report_id` consistent with the
|
|
105
|
+
// newly-inserted report row.
|
|
106
|
+
const txn = this.db.transaction(() => {
|
|
107
|
+
const ins = this.db.prepare(`
|
|
108
|
+
INSERT INTO recovery_reports
|
|
109
|
+
(signature_id, run_id, session_id, failed_attempts,
|
|
110
|
+
successful_strategy, changed_parameters, verification,
|
|
111
|
+
created_at, notes)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
113
|
+
`).run(opts.signatureId, opts.runId ?? null, opts.sessionId ?? null, opts.failedAttempts, opts.successfulStrategy, opts.changedParameters ? JSON.stringify(opts.changedParameters) : null, opts.verification ?? null, ts, opts.notes ?? null);
|
|
114
|
+
const reportId = Number(ins.lastInsertRowid);
|
|
115
|
+
this.db.prepare(`
|
|
116
|
+
UPDATE failure_signatures
|
|
117
|
+
SET recovered_count = recovered_count + 1,
|
|
118
|
+
last_recovery_report_id = ?
|
|
119
|
+
WHERE id = ?
|
|
120
|
+
`).run(reportId, opts.signatureId);
|
|
121
|
+
return reportId;
|
|
122
|
+
});
|
|
123
|
+
return txn();
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// eslint-disable-next-line no-console
|
|
127
|
+
console.warn('[recoveryStore] recordRecovery failed:', err instanceof Error ? err.message : String(err));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Top N recurring failure signatures, sorted by occurrence count
|
|
133
|
+
* descending. Backs `/recovery list`. Joins the most recent
|
|
134
|
+
* recovery_report so the operator sees what worked last.
|
|
135
|
+
*/
|
|
136
|
+
listTopFailures(limit = 10) {
|
|
137
|
+
const cap = Math.max(1, Math.min(limit, 500));
|
|
138
|
+
try {
|
|
139
|
+
const rows = this.db.prepare(`
|
|
140
|
+
SELECT
|
|
141
|
+
s.signature AS signature,
|
|
142
|
+
s.tool_name AS toolName,
|
|
143
|
+
s.failure_category AS failureCategory,
|
|
144
|
+
s.occurrences AS occurrences,
|
|
145
|
+
s.recovered_count AS recoveredCount,
|
|
146
|
+
s.last_seen_at AS lastSeenAt,
|
|
147
|
+
r.successful_strategy AS lastRecoveryStrategy
|
|
148
|
+
FROM failure_signatures s
|
|
149
|
+
LEFT JOIN recovery_reports r
|
|
150
|
+
ON r.id = s.last_recovery_report_id
|
|
151
|
+
ORDER BY s.occurrences DESC, s.last_seen_at DESC
|
|
152
|
+
LIMIT ?
|
|
153
|
+
`).all(cap);
|
|
154
|
+
return rows;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// eslint-disable-next-line no-console
|
|
158
|
+
console.warn('[recoveryStore] listTopFailures failed:', err instanceof Error ? err.message : String(err));
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Lookup one signature by its canonical string. Backs `/recovery
|
|
164
|
+
* show`. Returns null when no signature row exists yet.
|
|
165
|
+
*/
|
|
166
|
+
getBySignature(signature) {
|
|
167
|
+
try {
|
|
168
|
+
const row = this.db.prepare(`
|
|
169
|
+
SELECT
|
|
170
|
+
id AS id,
|
|
171
|
+
signature AS signature,
|
|
172
|
+
tool_name AS toolName,
|
|
173
|
+
failure_category AS failureCategory,
|
|
174
|
+
args_hash AS argsHash,
|
|
175
|
+
first_seen_at AS firstSeenAt,
|
|
176
|
+
last_seen_at AS lastSeenAt,
|
|
177
|
+
occurrences AS occurrences,
|
|
178
|
+
recovered_count AS recoveredCount,
|
|
179
|
+
last_recovery_report_id AS lastRecoveryReportId
|
|
180
|
+
FROM failure_signatures WHERE signature = ?
|
|
181
|
+
`).get(signature);
|
|
182
|
+
return row ?? null;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.warn('[recoveryStore] getBySignature failed:', err instanceof Error ? err.message : String(err));
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Recovery reports linked to one signature, most recent first.
|
|
192
|
+
* Used by `/recovery show` to render the recovery history below
|
|
193
|
+
* the signature header.
|
|
194
|
+
*/
|
|
195
|
+
listReportsForSignature(signatureId, limit = 50) {
|
|
196
|
+
const cap = Math.max(1, Math.min(limit, 500));
|
|
197
|
+
try {
|
|
198
|
+
const rows = this.db.prepare(`
|
|
199
|
+
SELECT
|
|
200
|
+
id AS id,
|
|
201
|
+
signature_id AS signatureId,
|
|
202
|
+
run_id AS runId,
|
|
203
|
+
session_id AS sessionId,
|
|
204
|
+
failed_attempts AS failedAttempts,
|
|
205
|
+
successful_strategy AS successfulStrategy,
|
|
206
|
+
changed_parameters AS changedParameters,
|
|
207
|
+
verification AS verification,
|
|
208
|
+
created_at AS createdAt,
|
|
209
|
+
notes AS notes
|
|
210
|
+
FROM recovery_reports
|
|
211
|
+
WHERE signature_id = ?
|
|
212
|
+
ORDER BY created_at DESC
|
|
213
|
+
LIMIT ?
|
|
214
|
+
`).all(signatureId, cap);
|
|
215
|
+
return rows;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
// eslint-disable-next-line no-console
|
|
219
|
+
console.warn('[recoveryStore] listReportsForSignature failed:', err instanceof Error ? err.message : String(err));
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Recovery reports written during one session, used by future
|
|
225
|
+
* plugin hooks + the `/recovery` command's per-session view.
|
|
226
|
+
* Wraps a single SELECT — no aggregation. Empty array when no
|
|
227
|
+
* recoveries happened.
|
|
228
|
+
*/
|
|
229
|
+
listForSession(sessionId) {
|
|
230
|
+
try {
|
|
231
|
+
const rows = this.db.prepare(`
|
|
232
|
+
SELECT
|
|
233
|
+
id AS id,
|
|
234
|
+
signature_id AS signatureId,
|
|
235
|
+
run_id AS runId,
|
|
236
|
+
session_id AS sessionId,
|
|
237
|
+
failed_attempts AS failedAttempts,
|
|
238
|
+
successful_strategy AS successfulStrategy,
|
|
239
|
+
changed_parameters AS changedParameters,
|
|
240
|
+
verification AS verification,
|
|
241
|
+
created_at AS createdAt,
|
|
242
|
+
notes AS notes
|
|
243
|
+
FROM recovery_reports
|
|
244
|
+
WHERE session_id = ?
|
|
245
|
+
ORDER BY created_at DESC
|
|
246
|
+
`).all(sessionId);
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// eslint-disable-next-line no-console
|
|
251
|
+
console.warn('[recoveryStore] listForSession failed:', err instanceof Error ? err.message : String(err));
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Operator escape hatch — `/recovery clear <signature>` lets the
|
|
257
|
+
* operator say "this is fixed, stop counting it." Cascades to
|
|
258
|
+
* the linked recovery_reports rows so the signature genuinely
|
|
259
|
+
* disappears. Returns true when a row was deleted.
|
|
260
|
+
*/
|
|
261
|
+
clearSignature(signature) {
|
|
262
|
+
try {
|
|
263
|
+
const sig = this.getBySignature(signature);
|
|
264
|
+
if (!sig)
|
|
265
|
+
return false;
|
|
266
|
+
const txn = this.db.transaction(() => {
|
|
267
|
+
this.db.prepare(`DELETE FROM recovery_reports WHERE signature_id = ?`).run(sig.id);
|
|
268
|
+
this.db.prepare(`DELETE FROM failure_signatures WHERE id = ?`).run(sig.id);
|
|
269
|
+
});
|
|
270
|
+
txn();
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
// eslint-disable-next-line no-console
|
|
275
|
+
console.warn('[recoveryStore] clearSignature failed:', err instanceof Error ? err.message : String(err));
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
exports.RecoveryStore = RecoveryStore;
|
|
281
|
+
// ── Module-level singleton ───────────────────────────────────────────────
|
|
282
|
+
let _singleton = null;
|
|
283
|
+
/**
|
|
284
|
+
* Initialise the process-wide store. Called once at REPL / daemon /
|
|
285
|
+
* MCP boot, after `runMigrations` has applied v7. Re-init replaces
|
|
286
|
+
* the singleton so tests can swap DBs cleanly.
|
|
287
|
+
*/
|
|
288
|
+
function initRecoveryStore(opts) {
|
|
289
|
+
_singleton = new RecoveryStore(opts.db);
|
|
290
|
+
return _singleton;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Read the current singleton. Returns null when not initialised so
|
|
294
|
+
* callers on the hot path (TCE write-through) can no-op silently
|
|
295
|
+
* instead of throwing. The slash command path (`/recovery list`)
|
|
296
|
+
* does its own "not initialised" error reporting.
|
|
297
|
+
*/
|
|
298
|
+
function getRecoveryStore() {
|
|
299
|
+
return _singleton;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Test-only — drop the singleton so the next `initRecoveryStore`
|
|
303
|
+
* call wires a fresh state.
|
|
304
|
+
*/
|
|
305
|
+
function _resetRecoveryStoreForTests() {
|
|
306
|
+
_singleton = null;
|
|
307
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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/selfimprovement/signatureBuilder.ts — v4.6 Phase 3b.
|
|
10
|
+
*
|
|
11
|
+
* Builds a stable, deterministic signature string for a failed tool
|
|
12
|
+
* call so equivalent failures collapse into one `failure_signatures`
|
|
13
|
+
* row. The shape is:
|
|
14
|
+
*
|
|
15
|
+
* <tool_name>:<failure_category>[:<args_hash_prefix>]
|
|
16
|
+
*
|
|
17
|
+
* The `args_hash_prefix` field is OPTIONAL. When the caller supplies
|
|
18
|
+
* `args`, this module normalises them (strips volatile fields like
|
|
19
|
+
* timestamps, run IDs, UUIDs, monotonic counters), serialises the
|
|
20
|
+
* result deterministically, and takes the first 6 hex chars of a
|
|
21
|
+
* SHA-256 digest. When `args` is omitted, the signature collapses to
|
|
22
|
+
* `<tool>:<category>` only — same logical failure, broader grouping.
|
|
23
|
+
*
|
|
24
|
+
* Granularity trade-offs:
|
|
25
|
+
*
|
|
26
|
+
* * Too granular ("every failure unique") → no aggregation; the
|
|
27
|
+
* `occurrences` column never increments past 1; operators can't
|
|
28
|
+
* see "this tool fails the same way over and over."
|
|
29
|
+
* * Too coarse ("only tool+category") → "file_read failed with
|
|
30
|
+
* `not_found`" groups EVERY missing file together; the operator
|
|
31
|
+
* can't tell which paths are sore points.
|
|
32
|
+
*
|
|
33
|
+
* The args-hash compromise: same tool + same category + same
|
|
34
|
+
* normalized args → same signature (good); same tool + same category
|
|
35
|
+
* + meaningfully different args → different signatures (also good).
|
|
36
|
+
* Volatile fields are stripped BEFORE hashing so re-hashing on a
|
|
37
|
+
* later turn produces the same signature even when only the
|
|
38
|
+
* timestamp / call id changes.
|
|
39
|
+
*
|
|
40
|
+
* Volatile field list (`VOLATILE_KEYS`) — defensive; covers the
|
|
41
|
+
* fields Aiden's tool layer tends to thread through args. Plugin
|
|
42
|
+
* authors who emit custom volatile keys should pre-normalise before
|
|
43
|
+
* calling this module.
|
|
44
|
+
*/
|
|
45
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
46
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.buildFailureSignature = buildFailureSignature;
|
|
50
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
51
|
+
// ── Implementation ───────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Keys whose values are stripped from the args object before hashing.
|
|
54
|
+
* These are fields that DO change between otherwise-identical
|
|
55
|
+
* failures (turn timestamps, run row ids, etc.) so leaving them in
|
|
56
|
+
* would prevent any signature from ever grouping.
|
|
57
|
+
*
|
|
58
|
+
* The list is intentionally narrow — only fields Aiden's tool layer
|
|
59
|
+
* is known to inject. Plugins emitting custom volatile keys must
|
|
60
|
+
* pre-normalise their args before calling this module.
|
|
61
|
+
*/
|
|
62
|
+
const VOLATILE_KEYS = new Set([
|
|
63
|
+
'timestamp',
|
|
64
|
+
'ts',
|
|
65
|
+
'requestId',
|
|
66
|
+
'request_id',
|
|
67
|
+
'runId',
|
|
68
|
+
'run_id',
|
|
69
|
+
'callId',
|
|
70
|
+
'call_id',
|
|
71
|
+
'sessionId',
|
|
72
|
+
'session_id',
|
|
73
|
+
'turnId',
|
|
74
|
+
'turn_id',
|
|
75
|
+
'eventId',
|
|
76
|
+
'event_id',
|
|
77
|
+
'createdAt',
|
|
78
|
+
'created_at',
|
|
79
|
+
'updatedAt',
|
|
80
|
+
'updated_at',
|
|
81
|
+
// Common UUID/idempotency-key names.
|
|
82
|
+
'uuid',
|
|
83
|
+
'idempotencyKey',
|
|
84
|
+
'idempotency_key',
|
|
85
|
+
]);
|
|
86
|
+
/**
|
|
87
|
+
* Deterministically stringify a value. Sorts object keys so
|
|
88
|
+
* `{a:1, b:2}` and `{b:2, a:1}` produce identical bytes. Strips
|
|
89
|
+
* volatile keys from any nested object before stringifying.
|
|
90
|
+
*
|
|
91
|
+
* Non-JSON-serialisable values (functions, symbols, circular refs)
|
|
92
|
+
* collapse to the literal string `'[unserializable]'` so the hash
|
|
93
|
+
* remains stable. Better-than-throwing is the right trade-off for
|
|
94
|
+
* a write-through hot path.
|
|
95
|
+
*/
|
|
96
|
+
function deterministicStringify(value) {
|
|
97
|
+
const seen = new WeakSet();
|
|
98
|
+
const visit = (v) => {
|
|
99
|
+
if (v === null || v === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
const t = typeof v;
|
|
102
|
+
if (t === 'string' || t === 'number' || t === 'boolean')
|
|
103
|
+
return v;
|
|
104
|
+
if (t === 'function' || t === 'symbol')
|
|
105
|
+
return '[unserializable]';
|
|
106
|
+
if (typeof v === 'bigint')
|
|
107
|
+
return v.toString();
|
|
108
|
+
if (Array.isArray(v)) {
|
|
109
|
+
if (seen.has(v))
|
|
110
|
+
return '[circular]';
|
|
111
|
+
seen.add(v);
|
|
112
|
+
return v.map(visit);
|
|
113
|
+
}
|
|
114
|
+
if (t === 'object') {
|
|
115
|
+
const obj = v;
|
|
116
|
+
if (seen.has(obj))
|
|
117
|
+
return '[circular]';
|
|
118
|
+
seen.add(obj);
|
|
119
|
+
const out = {};
|
|
120
|
+
const keys = Object.keys(obj).filter((k) => !VOLATILE_KEYS.has(k));
|
|
121
|
+
keys.sort();
|
|
122
|
+
for (const k of keys)
|
|
123
|
+
out[k] = visit(obj[k]);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return '[unserializable]';
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
return JSON.stringify(visit(value));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return '[unserializable]';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Build a failure signature. Pure function — no I/O, no side
|
|
137
|
+
* effects. Safe to call on the hot path of every classified
|
|
138
|
+
* failure; SHA-256 of a small JSON string is cheap (microseconds).
|
|
139
|
+
*/
|
|
140
|
+
function buildFailureSignature(input) {
|
|
141
|
+
const base = `${input.toolName}:${input.category}`;
|
|
142
|
+
if (input.args === undefined) {
|
|
143
|
+
return { signature: base };
|
|
144
|
+
}
|
|
145
|
+
const normalized = deterministicStringify(input.args);
|
|
146
|
+
// Empty / trivially-null args don't deserve a hash suffix —
|
|
147
|
+
// collapse to the base signature so "args: {}" and "no args"
|
|
148
|
+
// group together.
|
|
149
|
+
if (normalized === 'null' || normalized === '{}' || normalized === '[]') {
|
|
150
|
+
return { signature: base };
|
|
151
|
+
}
|
|
152
|
+
const digest = node_crypto_1.default.createHash('sha256').update(normalized).digest('hex');
|
|
153
|
+
const argsHash = digest.slice(0, 6);
|
|
154
|
+
return {
|
|
155
|
+
signature: `${base}:${argsHash}`,
|
|
156
|
+
argsHash,
|
|
157
|
+
};
|
|
158
|
+
}
|