aiden-runtime 4.9.0 → 4.9.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 +1 -1
- package/dist/cli/v4/aidenCLI.js +2 -2
- package/dist/cli/v4/aidenPrompt.js +12 -0
- package/dist/cli/v4/chatSession.js +43 -17
- package/dist/cli/v4/commands/channel.js +4 -6
- package/dist/cli/v4/commands/cron.js +6 -1
- package/dist/cli/v4/commands/daemon.js +6 -1
- package/dist/cli/v4/commands/daemonDoctor.js +6 -6
- package/dist/cli/v4/commands/daemonStatus.js +46 -27
- package/dist/cli/v4/commands/help.js +3 -0
- package/dist/cli/v4/commands/hooks.js +39 -1
- package/dist/cli/v4/commands/hooksSlash.js +33 -0
- package/dist/cli/v4/commands/index.js +9 -1
- package/dist/cli/v4/commands/mcp.js +2 -2
- package/dist/cli/v4/commands/memory.js +6 -1
- package/dist/cli/v4/commands/memorySlash.js +38 -0
- package/dist/cli/v4/commands/plugins.js +4 -6
- package/dist/cli/v4/commands/trigger.js +18 -18
- package/dist/cli/v4/confirmPrompt.js +67 -0
- package/dist/cli/v4/ui/progressBar.js +179 -0
- package/dist/cli/v4/util/closestAction.js +48 -0
- package/dist/core/v4/daemon/db/migrations.js +398 -398
- package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +10 -10
- package/dist/core/v4/daemon/incarnationStore.js +9 -9
- package/dist/core/v4/daemon/runs/attemptStore.js +8 -8
- package/dist/core/v4/daemon/runs/reclaim.js +12 -12
- package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +19 -19
- package/dist/core/v4/daemon/spans/spanStore.js +14 -14
- package/dist/core/v4/daemon/triggerBus.js +61 -61
- package/dist/core/v4/hooks/auditQuery.js +11 -11
- package/dist/core/v4/hooks/dispatcher.js +13 -13
- package/dist/core/v4/hooks/registry.js +8 -8
- package/dist/core/v4/mcp/transport.js +9 -9
- package/dist/core/v4/update/depWarningFilter.js +76 -0
- package/dist/core/v4/update/executeInstall.js +70 -53
- package/dist/core/v4/update/platformInstructions.js +128 -0
- package/dist/core/v4/update/recoveryScript.js +70 -0
- package/dist/core/v4/util/spawnCommand.js +151 -0
- package/package.json +1 -1
- package/themes/default.yaml +52 -52
- package/themes/dracula.yaml +32 -32
- package/themes/light.yaml +32 -32
- package/themes/monochrome.yaml +31 -31
- package/themes/tokyo-night.yaml +32 -32
|
@@ -79,9 +79,9 @@ async function runTriggerSubcommand(action, args, argv, opts = {}) {
|
|
|
79
79
|
spec.paths = spec.paths.map((p) => node_path_1.default.resolve(p));
|
|
80
80
|
const id = (0, node_crypto_1.randomUUID)();
|
|
81
81
|
const now = Date.now();
|
|
82
|
-
db.prepare(`INSERT INTO triggers
|
|
83
|
-
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
84
|
-
created_at, updated_at)
|
|
82
|
+
db.prepare(`INSERT INTO triggers
|
|
83
|
+
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
84
|
+
created_at, updated_at)
|
|
85
85
|
VALUES (?, 'file', ?, ?, ?, ?, 0, ?, ?)`).run(id, a.name, JSON.stringify(spec), a.disabled ? 0 : 1, spec.promptTemplate ?? null, now, now);
|
|
86
86
|
out(`trigger added: ${id} (${a.name})\n`);
|
|
87
87
|
out('Restart the daemon to activate the watcher.\n');
|
|
@@ -182,11 +182,11 @@ async function runTriggerSubcommand(action, args, argv, opts = {}) {
|
|
|
182
182
|
return 1;
|
|
183
183
|
}
|
|
184
184
|
const prefix = `trigger:${trig.source}:${id}:`;
|
|
185
|
-
const rows = db.prepare(`SELECT re.ts, re.kind, re.payload, r.id AS run_id
|
|
186
|
-
FROM run_events re
|
|
187
|
-
JOIN runs r ON re.run_id = r.id
|
|
188
|
-
WHERE r.session_id LIKE ?
|
|
189
|
-
ORDER BY re.ts DESC
|
|
185
|
+
const rows = db.prepare(`SELECT re.ts, re.kind, re.payload, r.id AS run_id
|
|
186
|
+
FROM run_events re
|
|
187
|
+
JOIN runs r ON re.run_id = r.id
|
|
188
|
+
WHERE r.session_id LIKE ?
|
|
189
|
+
ORDER BY re.ts DESC
|
|
190
190
|
LIMIT 50`).all(`${prefix}%`);
|
|
191
191
|
if (rows.length === 0) {
|
|
192
192
|
out(`No run events recorded for trigger ${id} (${trig.name}).\n`);
|
|
@@ -213,10 +213,10 @@ async function runTriggerSubcommand(action, args, argv, opts = {}) {
|
|
|
213
213
|
return 1;
|
|
214
214
|
}
|
|
215
215
|
const prefix = `trigger:${trig.source}:${id}:`;
|
|
216
|
-
const rows = db.prepare(`SELECT id, status, finish_reason, started_at, completed_at
|
|
217
|
-
FROM runs
|
|
218
|
-
WHERE session_id LIKE ?
|
|
219
|
-
ORDER BY started_at DESC
|
|
216
|
+
const rows = db.prepare(`SELECT id, status, finish_reason, started_at, completed_at
|
|
217
|
+
FROM runs
|
|
218
|
+
WHERE session_id LIKE ?
|
|
219
|
+
ORDER BY started_at DESC
|
|
220
220
|
LIMIT 50`).all(`${prefix}%`);
|
|
221
221
|
if (rows.length === 0) {
|
|
222
222
|
out(`No runs recorded for trigger ${id} (${trig.name}).\n`);
|
|
@@ -261,9 +261,9 @@ function runAddWebhook(db, argv, out, err) {
|
|
|
261
261
|
});
|
|
262
262
|
const id = (0, node_crypto_1.randomUUID)();
|
|
263
263
|
const now = Date.now();
|
|
264
|
-
db.prepare(`INSERT INTO triggers
|
|
265
|
-
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
266
|
-
created_at, updated_at)
|
|
264
|
+
db.prepare(`INSERT INTO triggers
|
|
265
|
+
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
266
|
+
created_at, updated_at)
|
|
267
267
|
VALUES (?, 'webhook', ?, ?, ?, ?, ?, ?, ?)`).run(id, a.name, JSON.stringify(spec), a.disabled ? 0 : 1, spec.promptTemplate ?? null, spec.deliverOnly ? 1 : 0, now, now);
|
|
268
268
|
const cfg = (0, daemon_1.getDaemonConfig)();
|
|
269
269
|
const host = process.env.AIDEN_DAEMON_BIND ?? '127.0.0.1';
|
|
@@ -359,9 +359,9 @@ async function runAddEmail(db, argv, out, err) {
|
|
|
359
359
|
}
|
|
360
360
|
const id = (0, node_crypto_1.randomUUID)();
|
|
361
361
|
const now = Date.now();
|
|
362
|
-
db.prepare(`INSERT INTO triggers
|
|
363
|
-
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
364
|
-
created_at, updated_at)
|
|
362
|
+
db.prepare(`INSERT INTO triggers
|
|
363
|
+
(id, source, name, spec_json, enabled, prompt_template, deliver_only,
|
|
364
|
+
created_at, updated_at)
|
|
365
365
|
VALUES (?, 'email', ?, ?, ?, ?, ?, ?, ?)`).run(id, a.name, JSON.stringify(spec), a.disabled ? 0 : 1, spec.promptTemplate ?? null, spec.deliverOnly ? 1 : 0, now, now);
|
|
366
366
|
out(`trigger added: ${id} (${a.name})\n`);
|
|
367
367
|
out(`imap host: ${spec.imap.host}:${spec.imap.port}${spec.imap.tls ? ' (TLS)' : ''}\n`);
|
|
@@ -0,0 +1,67 @@
|
|
|
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/confirmPrompt.ts — v4.9.2 SLICE 3.
|
|
10
|
+
*
|
|
11
|
+
* The slash-command `ctx.confirm()` primitive, extracted from the
|
|
12
|
+
* chatSession closure so it has one source of truth + is unit-testable
|
|
13
|
+
* without spinning up a full REPL.
|
|
14
|
+
*
|
|
15
|
+
* Behaviour:
|
|
16
|
+
* - Canonicalises the y/n hint: strips any caller-appended ` (y/N) `
|
|
17
|
+
* / ` [y/N] ` / ` (Y/n) ` so the primitive can append exactly one
|
|
18
|
+
* ` (y/N) ` in canonical lowercase-y / capital-N form.
|
|
19
|
+
* - Prefixes with a warn-tinted `?` glyph so the confirmation chrome
|
|
20
|
+
* is visually distinct from the main ▲ chat prompt (Slice 3 root
|
|
21
|
+
* cause: users couldn't tell a prompt was open).
|
|
22
|
+
* - Routes through `promptApi.readLine` with `suggestionsDisabled:true`
|
|
23
|
+
* so the inquirer-input path runs (no ghost-text from outer chat
|
|
24
|
+
* history, no slash dropdown — irrelevant for y/n).
|
|
25
|
+
* - Emits a per-input cancellation reason:
|
|
26
|
+
* empty / Enter alone → "Cancelled (press 'y' to confirm; …)"
|
|
27
|
+
* 'n' / 'no' → "Cancelled." (deliberate decline)
|
|
28
|
+
* other non-y → `Cancelled ("<x>" not recognized — …)`
|
|
29
|
+
* null / non-string → "Cancelled (no input)."
|
|
30
|
+
* Callers no longer print their own "Cancelled." line — the
|
|
31
|
+
* primitive owns the rejection message.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.runConfirm = runConfirm;
|
|
35
|
+
/** Strip any caller-appended `(y/N)` / `[y/N]` / `(Y/n)` so we can
|
|
36
|
+
* re-append the canonical hint without duplication. */
|
|
37
|
+
const TRAILING_YN_HINT_RE = /\s*[\[(](y\/[nN]|Y\/n)[\])]\s*$/i;
|
|
38
|
+
/**
|
|
39
|
+
* Run a single confirmation prompt. Resolves to `true` on `y` / `yes`
|
|
40
|
+
* (case insensitive, trimmed); `false` on anything else, with a
|
|
41
|
+
* specific cancellation line written to `display.dim()`.
|
|
42
|
+
*
|
|
43
|
+
* Never throws — readLine errors and non-string returns degrade to
|
|
44
|
+
* `false` with an honest "no input" reason.
|
|
45
|
+
*/
|
|
46
|
+
async function runConfirm(msg, promptApi, display) {
|
|
47
|
+
const stripped = msg.replace(TRAILING_YN_HINT_RE, '').trimEnd();
|
|
48
|
+
const decorated = `${display.paint('?', 'warn')} ${stripped} (y/N) `;
|
|
49
|
+
const r = await promptApi.readLine(decorated, { suggestionsDisabled: true });
|
|
50
|
+
if (typeof r !== 'string') {
|
|
51
|
+
display.dim('Cancelled (no input).');
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const trimmed = r.trim();
|
|
55
|
+
if (/^(y|yes)$/i.test(trimmed))
|
|
56
|
+
return true;
|
|
57
|
+
if (trimmed === '') {
|
|
58
|
+
display.dim(`Cancelled (press 'y' to confirm; Enter alone = no).`);
|
|
59
|
+
}
|
|
60
|
+
else if (/^(n|no)$/i.test(trimmed)) {
|
|
61
|
+
display.dim('Cancelled.');
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
display.dim(`Cancelled ("${trimmed}" not recognized — expected y/yes/n/no).`);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
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/ui/progressBar.ts — v4.9.1 reusable progress animation.
|
|
10
|
+
* Auto-detects TTY / NO_COLOR / TERM=dumb / CI to pick render mode
|
|
11
|
+
* (block glyphs vs `#-`, color vs plain, animated vs once-per-second
|
|
12
|
+
* non-TTY lines). Cursor hidden during animation, restored on exit.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.detectRenderMode = detectRenderMode;
|
|
16
|
+
exports.renderLine = renderLine;
|
|
17
|
+
exports.startProgressBar = startProgressBar;
|
|
18
|
+
exports.npmInstallPhasePercent = npmInstallPhasePercent;
|
|
19
|
+
exports.detectNpmPhase = detectNpmPhase;
|
|
20
|
+
const DEFAULT_WIDTH = 28;
|
|
21
|
+
const DEFAULT_TICK_MS = 100;
|
|
22
|
+
/** Minimum elapsed before we paint anything — avoids flicker on sub-300ms ops. */
|
|
23
|
+
const PAINT_AFTER_MS = 300;
|
|
24
|
+
const ANSI_HIDE_CURSOR = '\x1b[?25l';
|
|
25
|
+
const ANSI_SHOW_CURSOR = '\x1b[?25h';
|
|
26
|
+
const ANSI_CLEAR_LINE = '\r\x1b[2K';
|
|
27
|
+
const ANSI_BRAND = '\x1b[38;2;255;107;53m'; // RGB 255,107,53 (Aiden orange)
|
|
28
|
+
const ANSI_MUTED = '\x1b[38;2;106;106;106m';
|
|
29
|
+
const ANSI_SUCCESS = '\x1b[38;2;127;194;139m';
|
|
30
|
+
const ANSI_ERROR = '\x1b[38;2;224;90;90m';
|
|
31
|
+
const ANSI_RESET = '\x1b[0m';
|
|
32
|
+
/** Detect the right render mode from TTY + env. Pure function. */
|
|
33
|
+
function detectRenderMode(isTTY, env = process.env) {
|
|
34
|
+
if (!isTTY)
|
|
35
|
+
return { color: false, blocks: false, animated: false };
|
|
36
|
+
const noColor = env.NO_COLOR !== undefined && env.NO_COLOR !== '';
|
|
37
|
+
const dumb = env.TERM === 'dumb' || env.CI === 'true' || env.CI === '1';
|
|
38
|
+
return {
|
|
39
|
+
color: !noColor && !dumb,
|
|
40
|
+
blocks: !dumb,
|
|
41
|
+
animated: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the rendered line (without trailing newline). Pure so tests
|
|
46
|
+
* can assert byte-for-byte without timing.
|
|
47
|
+
*/
|
|
48
|
+
function renderLine(opts) {
|
|
49
|
+
const pct = Math.max(0, Math.min(100, Math.round(opts.percent)));
|
|
50
|
+
const filled = Math.round((pct / 100) * opts.width);
|
|
51
|
+
const empty = opts.width - filled;
|
|
52
|
+
const full = opts.mode.blocks ? '█' : '#';
|
|
53
|
+
const blank = opts.mode.blocks ? '░' : '-';
|
|
54
|
+
const elapsed = `${(opts.elapsedMs / 1000).toFixed(1)}s`;
|
|
55
|
+
const bar = full.repeat(filled) + blank.repeat(empty);
|
|
56
|
+
if (opts.mode.color) {
|
|
57
|
+
return `${ANSI_BRAND}[${bar}]${ANSI_RESET} ${pct}% ${ANSI_MUTED}${opts.phase}${ANSI_RESET} ${ANSI_MUTED}${elapsed}${ANSI_RESET}`;
|
|
58
|
+
}
|
|
59
|
+
return `[${bar}] ${pct}% ${opts.phase} ${elapsed}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Start a progress bar. Returns a controller object. Never throws —
|
|
63
|
+
* any I/O failure on output degrades the bar to a silent no-op while
|
|
64
|
+
* still honoring `complete` / `fail` semantics for the caller.
|
|
65
|
+
*/
|
|
66
|
+
function startProgressBar(opts) {
|
|
67
|
+
const width = opts.width ?? DEFAULT_WIDTH;
|
|
68
|
+
const out = opts.out ?? process.stdout;
|
|
69
|
+
const env = opts.env ?? process.env;
|
|
70
|
+
const tickMs = opts.tickMs ?? DEFAULT_TICK_MS;
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
+
const isTTY = opts.isTTY ?? Boolean(out.isTTY);
|
|
73
|
+
const mode = detectRenderMode(isTTY, env);
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
let phase = opts.phases[0] ?? '';
|
|
76
|
+
let percent = 0;
|
|
77
|
+
let painted = false;
|
|
78
|
+
let closed = false;
|
|
79
|
+
const write = (s) => {
|
|
80
|
+
try {
|
|
81
|
+
out.write(s);
|
|
82
|
+
}
|
|
83
|
+
catch { /* swallow — never break caller */ }
|
|
84
|
+
};
|
|
85
|
+
// SIGINT: restore cursor + clear the partial line before bubbling.
|
|
86
|
+
const onSigint = () => {
|
|
87
|
+
try {
|
|
88
|
+
write(ANSI_CLEAR_LINE + ANSI_SHOW_CURSOR);
|
|
89
|
+
}
|
|
90
|
+
catch { /* noop */ }
|
|
91
|
+
};
|
|
92
|
+
if (mode.animated) {
|
|
93
|
+
try {
|
|
94
|
+
process.once('SIGINT', onSigint);
|
|
95
|
+
}
|
|
96
|
+
catch { /* noop */ }
|
|
97
|
+
}
|
|
98
|
+
// Label line paints once, immediately.
|
|
99
|
+
write(`${mode.color ? ANSI_MUTED : ''}${opts.label}${mode.color ? ANSI_RESET : ''}\n`);
|
|
100
|
+
const paint = () => {
|
|
101
|
+
if (closed)
|
|
102
|
+
return;
|
|
103
|
+
const elapsedMs = Date.now() - startedAt;
|
|
104
|
+
if (elapsedMs < PAINT_AFTER_MS)
|
|
105
|
+
return;
|
|
106
|
+
const line = renderLine({ width, percent, phase, elapsedMs, mode });
|
|
107
|
+
if (mode.animated) {
|
|
108
|
+
if (!painted) {
|
|
109
|
+
write(ANSI_HIDE_CURSOR);
|
|
110
|
+
painted = true;
|
|
111
|
+
}
|
|
112
|
+
write(ANSI_CLEAR_LINE + line);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
write(line + '\n');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let timer = null;
|
|
119
|
+
if (mode.animated) {
|
|
120
|
+
timer = setInterval(paint, tickMs);
|
|
121
|
+
if (typeof timer.unref === 'function')
|
|
122
|
+
timer.unref();
|
|
123
|
+
}
|
|
124
|
+
const close = (icon, color, message) => {
|
|
125
|
+
if (closed)
|
|
126
|
+
return;
|
|
127
|
+
closed = true;
|
|
128
|
+
if (timer)
|
|
129
|
+
clearInterval(timer);
|
|
130
|
+
try {
|
|
131
|
+
process.removeListener('SIGINT', onSigint);
|
|
132
|
+
}
|
|
133
|
+
catch { /* noop */ }
|
|
134
|
+
const finalLine = mode.color
|
|
135
|
+
? `${color}${icon}${ANSI_RESET} ${message}`
|
|
136
|
+
: `${icon} ${message}`;
|
|
137
|
+
if (mode.animated && painted)
|
|
138
|
+
write(ANSI_CLEAR_LINE);
|
|
139
|
+
write(finalLine + '\n');
|
|
140
|
+
if (mode.animated)
|
|
141
|
+
write(ANSI_SHOW_CURSOR);
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
setPhase(name) { phase = name; if (!mode.animated)
|
|
145
|
+
paint(); },
|
|
146
|
+
setPercent(p) { percent = p; if (!mode.animated)
|
|
147
|
+
paint(); },
|
|
148
|
+
complete(message) { close('✓', ANSI_SUCCESS, message); },
|
|
149
|
+
fail(message) { close('✗', ANSI_ERROR, message); },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** npm install phase → default percent. Best-effort bar shaping. */
|
|
153
|
+
function npmInstallPhasePercent(phase) {
|
|
154
|
+
switch (phase) {
|
|
155
|
+
case 'spawning': return 3;
|
|
156
|
+
case 'resolving': return 15;
|
|
157
|
+
case 'downloading': return 50;
|
|
158
|
+
case 'extracting': return 85;
|
|
159
|
+
case 'verifying': return 97;
|
|
160
|
+
case 'installed': return 100;
|
|
161
|
+
case 'failed': return 100;
|
|
162
|
+
default: return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Detect npm phase from a stdout/stderr line. Checks ordered for npm 9/10/11. */
|
|
166
|
+
function detectNpmPhase(line) {
|
|
167
|
+
const l = line.toLowerCase();
|
|
168
|
+
if (l.includes('added ') && l.includes('package'))
|
|
169
|
+
return 'verifying';
|
|
170
|
+
if (l.includes('http fetch'))
|
|
171
|
+
return 'downloading';
|
|
172
|
+
if (l.includes('extracting') || l.includes('extract'))
|
|
173
|
+
return 'extracting';
|
|
174
|
+
if (l.includes('reify:'))
|
|
175
|
+
return 'downloading';
|
|
176
|
+
if (l.includes('resolved') || l.includes('audit'))
|
|
177
|
+
return 'resolving';
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.closestAction = closestAction;
|
|
4
|
+
/**
|
|
5
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
6
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
7
|
+
*
|
|
8
|
+
* Aiden — local-first agent.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* cli/v4/util/closestAction.ts — v4.9.1 amendment.
|
|
12
|
+
* Suggest the closest known action when the user mis-types a subcommand.
|
|
13
|
+
* Matches if input is a substring of a known action OR Levenshtein
|
|
14
|
+
* distance ≤ 2. Returns null when nothing is reasonably close.
|
|
15
|
+
*/
|
|
16
|
+
function lev(a, b) {
|
|
17
|
+
const m = a.length, n = b.length;
|
|
18
|
+
if (m === 0)
|
|
19
|
+
return n;
|
|
20
|
+
if (n === 0)
|
|
21
|
+
return m;
|
|
22
|
+
const row = Array.from({ length: n + 1 }, (_, i) => i);
|
|
23
|
+
for (let i = 1; i <= m; i++) {
|
|
24
|
+
let prev = i - 1;
|
|
25
|
+
row[0] = i;
|
|
26
|
+
for (let j = 1; j <= n; j++) {
|
|
27
|
+
const cur = row[j];
|
|
28
|
+
row[j] = a[i - 1] === b[j - 1] ? prev : Math.min(prev, row[j - 1], row[j]) + 1;
|
|
29
|
+
prev = cur;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return row[n];
|
|
33
|
+
}
|
|
34
|
+
function closestAction(input, known) {
|
|
35
|
+
if (!input)
|
|
36
|
+
return null;
|
|
37
|
+
const lo = input.toLowerCase();
|
|
38
|
+
let best = null;
|
|
39
|
+
for (const k of known) {
|
|
40
|
+
const kl = k.toLowerCase();
|
|
41
|
+
if (kl.includes(lo) || lo.includes(kl))
|
|
42
|
+
return k;
|
|
43
|
+
const d = lev(lo, kl);
|
|
44
|
+
if (d <= 2 && (!best || d < best.d))
|
|
45
|
+
best = { name: k, d };
|
|
46
|
+
}
|
|
47
|
+
return best?.name ?? null;
|
|
48
|
+
}
|