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