aiden-runtime 4.0.1 → 4.1.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.
Files changed (112) hide show
  1. package/README.md +11 -7
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +513 -14
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +269 -52
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +19 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/setup.js +34 -0
  20. package/dist/cli/v4/commands/show.js +43 -0
  21. package/dist/cli/v4/commands/skills.js +169 -4
  22. package/dist/cli/v4/commands/status.js +84 -0
  23. package/dist/cli/v4/commands/subagent.js +78 -0
  24. package/dist/cli/v4/commands/verbose.js +1 -1
  25. package/dist/cli/v4/commands/voice.js +218 -0
  26. package/dist/cli/v4/cronCli.js +103 -0
  27. package/dist/cli/v4/display.js +300 -14
  28. package/dist/cli/v4/doctor.js +41 -0
  29. package/dist/cli/v4/envSources.js +105 -0
  30. package/dist/cli/v4/ghostMatch.js +74 -0
  31. package/dist/cli/v4/historyStore.js +163 -0
  32. package/dist/cli/v4/pasteCompression.js +124 -0
  33. package/dist/cli/v4/pasteIntercept.js +203 -0
  34. package/dist/cli/v4/replyRenderer.js +209 -0
  35. package/dist/cli/v4/resizeGuard.js +92 -0
  36. package/dist/cli/v4/setupWizard.js +466 -232
  37. package/dist/cli/v4/shellInterpolation.js +139 -0
  38. package/dist/cli/v4/skinEngine.js +21 -1
  39. package/dist/cli/v4/streamingPrefix.js +121 -0
  40. package/dist/cli/v4/syntaxHighlight.js +345 -0
  41. package/dist/cli/v4/table.js +216 -0
  42. package/dist/cli/v4/themeDetect.js +81 -0
  43. package/dist/cli/v4/uiBuild.js +74 -0
  44. package/dist/cli/v4/voiceCli.js +113 -0
  45. package/dist/cli/v4/voicePromptApi.js +196 -0
  46. package/dist/core/channels/discord.js +16 -10
  47. package/dist/core/channels/email.js +13 -9
  48. package/dist/core/channels/imessage.js +13 -9
  49. package/dist/core/channels/manager.js +25 -7
  50. package/dist/core/channels/pdf-extract.js +180 -0
  51. package/dist/core/channels/photo-vision.js +157 -0
  52. package/dist/core/channels/signal.js +11 -7
  53. package/dist/core/channels/slack.js +13 -10
  54. package/dist/core/channels/telegram-commands.js +154 -0
  55. package/dist/core/channels/telegram-groups.js +198 -0
  56. package/dist/core/channels/telegram-rate-limit.js +124 -0
  57. package/dist/core/channels/telegram.js +1980 -0
  58. package/dist/core/channels/twilio.js +11 -7
  59. package/dist/core/channels/webhook.js +9 -5
  60. package/dist/core/channels/whatsapp.js +15 -11
  61. package/dist/core/channels/whisper-transcribe.js +163 -0
  62. package/dist/core/cronManager.js +33 -294
  63. package/dist/core/gateway.js +29 -8
  64. package/dist/core/playwrightBridge.js +90 -0
  65. package/dist/core/v4/aidenAgent.js +35 -0
  66. package/dist/core/v4/auxiliaryClient.js +2 -2
  67. package/dist/core/v4/cron/atomicWrite.js +18 -4
  68. package/dist/core/v4/cron/cronExecute.js +300 -0
  69. package/dist/core/v4/cron/cronManager.js +502 -0
  70. package/dist/core/v4/cron/cronState.js +314 -0
  71. package/dist/core/v4/cron/cronTick.js +90 -0
  72. package/dist/core/v4/cron/diagnostics.js +104 -0
  73. package/dist/core/v4/cron/graceWindow.js +79 -0
  74. package/dist/core/v4/firstRun/providerDetection.js +287 -0
  75. package/dist/core/v4/logger/factory.js +110 -0
  76. package/dist/core/v4/logger/index.js +22 -0
  77. package/dist/core/v4/logger/logger.js +101 -0
  78. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  79. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  80. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  81. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  82. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  83. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  84. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  85. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  86. package/dist/core/v4/platformPaths.js +105 -0
  87. package/dist/core/v4/providerFallback.js +25 -0
  88. package/dist/core/v4/skillLoader.js +21 -5
  89. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  90. package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
  91. package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
  92. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  93. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  94. package/dist/core/v4/subagent/budget.js +76 -0
  95. package/dist/core/v4/subagent/diagnostics.js +22 -0
  96. package/dist/core/v4/subagent/fanout.js +216 -0
  97. package/dist/core/v4/subagent/merger.js +148 -0
  98. package/dist/core/v4/subagent/providerRotation.js +54 -0
  99. package/dist/core/v4/voice/audioStream.js +373 -0
  100. package/dist/core/v4/voice/cliVoice.js +393 -0
  101. package/dist/core/v4/voice/diagnostics.js +66 -0
  102. package/dist/core/v4/voice/ttsStream.js +193 -0
  103. package/dist/core/version.js +1 -1
  104. package/dist/core/visionAnalyze.js +291 -90
  105. package/dist/core/voice/audio.js +61 -5
  106. package/dist/core/voice/audioBackend.js +134 -0
  107. package/dist/core/voice/stt.js +61 -6
  108. package/dist/core/voice/tts.js +19 -3
  109. package/dist/providers/v4/nullAdapter.js +58 -0
  110. package/dist/tools/v4/index.js +32 -1
  111. package/dist/tools/v4/subagent/subagentFanout.js +166 -0
  112. package/package.json +11 -2
@@ -0,0 +1,216 @@
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/table.ts — lightweight ASCII table renderer (Tier-3.1).
10
+ *
11
+ * Drop-in replacement for `Display.twoColumnBlock` style output at
12
+ * call sites that want full multi-column tables (`/skills`,
13
+ * `/cron list`, `/channel list`). No `cli-table3` dependency — the
14
+ * renderer is ~150 lines, ANSI-aware via `string-width`, and uses
15
+ * the same SkinEngine colour kinds as the rest of v4.
16
+ *
17
+ * Box drawing is sharp ASCII (`─ │ ┌ ┐ └ ┘ ├ ┤`) to stay aligned
18
+ * with the rest of the v4.1-tier3.1 box pass.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.renderTable = renderTable;
22
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
23
+ const stringWidth = require('string-width');
24
+ const skinEngine_1 = require("./skinEngine");
25
+ const box_1 = require("./box");
26
+ /**
27
+ * Visible (post-ANSI-strip) column width. Falls back to
28
+ * `visibleLength` from box.ts when string-width is unavailable
29
+ * (which would only happen if the dep was removed).
30
+ */
31
+ function vWidth(s) {
32
+ try {
33
+ return stringWidth(s);
34
+ }
35
+ catch {
36
+ return (0, box_1.visibleLength)(s);
37
+ }
38
+ }
39
+ /** Pad `s` to `w` visible columns using `align`. ANSI-safe. */
40
+ function pad(s, w, align = 'left') {
41
+ const sw = vWidth(s);
42
+ if (sw >= w)
43
+ return s;
44
+ const gap = w - sw;
45
+ if (align === 'right')
46
+ return ' '.repeat(gap) + s;
47
+ if (align === 'center') {
48
+ const l = Math.floor(gap / 2);
49
+ return ' '.repeat(l) + s + ' '.repeat(gap - l);
50
+ }
51
+ return s + ' '.repeat(gap);
52
+ }
53
+ /** Truncate to `max` visible columns with a single `…` tail. */
54
+ function truncCell(s, max) {
55
+ if (vWidth(s) <= max)
56
+ return s;
57
+ if (max <= 1)
58
+ return '…';
59
+ return (0, box_1.truncateVisible)(s, max - 1) + '…';
60
+ }
61
+ /** Resolve a column's display string for one row. */
62
+ function cellValue(row, col) {
63
+ const raw = row[col.key];
64
+ const v = col.format ? col.format(raw, row) : (raw == null ? '' : String(raw));
65
+ if (col.truncate && vWidth(v) > col.truncate) {
66
+ return truncCell(v, col.truncate);
67
+ }
68
+ return v;
69
+ }
70
+ /**
71
+ * Tier-3.1b: word-boundary-aware truncate. Tries to cut at the last
72
+ * space inside `[max*0.5, max-1]` and append `…`. Falls back to the
73
+ * dumb mid-word cut when no space lives in that range. Never produces
74
+ * a result wider than `max`.
75
+ */
76
+ function smartTrunc(s, max) {
77
+ if (vWidth(s) <= max)
78
+ return s;
79
+ if (max <= 1)
80
+ return '…';
81
+ const candidate = (0, box_1.truncateVisible)(s, max - 1);
82
+ // Word-boundary search — only honour spaces that leave at least
83
+ // half the column populated, otherwise the cell looks empty.
84
+ const lastSpace = candidate.lastIndexOf(' ');
85
+ if (lastSpace >= Math.floor(max * 0.5)) {
86
+ return candidate.slice(0, lastSpace) + '…';
87
+ }
88
+ return candidate + '…';
89
+ }
90
+ /**
91
+ * Tier-3.1b: allocate per-column widths to fit `available` chars.
92
+ * Non-flex columns prefer their natural width; flex columns absorb
93
+ * the leftover space proportional to their natural sizes. When even
94
+ * fixed columns overflow, every column is shrunk proportionally with
95
+ * a hard floor of 8 chars per column.
96
+ */
97
+ function allocateWidths(cols, natural, available) {
98
+ const numCols = cols.length;
99
+ const totalNatural = natural.reduce((a, b) => a + b, 0);
100
+ if (totalNatural <= available)
101
+ return natural.slice();
102
+ // If any column declared flex:true, treat those as flex; otherwise
103
+ // the last column carries the flex flag (description-most case).
104
+ const explicitFlex = cols.some((c) => c.flex === true);
105
+ const flexFlags = cols.map((c, i) => explicitFlex ? c.flex === true : i === numCols - 1);
106
+ const fixedSum = natural.reduce((s, w, i) => s + (flexFlags[i] ? 0 : w), 0);
107
+ const flexNaturalSum = natural.reduce((s, w, i) => s + (flexFlags[i] ? w : 0), 0);
108
+ if (fixedSum >= available || flexNaturalSum === 0) {
109
+ // Even fixed columns don't fit — proportional shrink everything.
110
+ const ratio = available / Math.max(1, totalNatural);
111
+ return natural.map((w) => Math.max(8, Math.floor(w * ratio)));
112
+ }
113
+ const remainingForFlex = available - fixedSum;
114
+ return natural.map((w, i) => {
115
+ if (!flexFlags[i])
116
+ return w;
117
+ return Math.max(8, Math.floor(remainingForFlex * (w / flexNaturalSum)));
118
+ });
119
+ }
120
+ /**
121
+ * Render `rows` as an ASCII table. Returns the multi-line string
122
+ * (with a trailing `\n`); caller writes it via the display.
123
+ */
124
+ function renderTable(rows, cols, opts = {}) {
125
+ const skin = (0, skinEngine_1.getSkinEngine)();
126
+ const indent = opts.indent ?? 2;
127
+ const showRule = opts.showHeaderRule !== false;
128
+ // Pre-compute uncoloured cell values so width math sees exact text.
129
+ const valueGrid = rows.map((row) => cols.map((c) => cellValue(row, c)));
130
+ // Natural widths — max(header, longest cell, minWidth).
131
+ const naturalWidths = cols.map((c, i) => {
132
+ let w = vWidth(c.header);
133
+ for (const rowVals of valueGrid) {
134
+ const cw = vWidth(rowVals[i]);
135
+ if (cw > w)
136
+ w = cw;
137
+ }
138
+ if (c.minWidth && c.minWidth > w)
139
+ w = c.minWidth;
140
+ return w;
141
+ });
142
+ // Tier-3.1b: responsive width allocation. Total table chars =
143
+ // indent + 1 (left border) + sum(width+2) + (numCols-1) inner
144
+ // separators + 1 (right border). Solve for content budget given
145
+ // the caller-provided maxWidth (or terminal columns).
146
+ const numCols = cols.length;
147
+ const overhead = indent + 3 * numCols + 1;
148
+ // Honor an explicit override first, then the live TTY width, then
149
+ // the COLUMNS env var (set by `term`-aware shells and most spawned
150
+ // subprocess wrappers — process.stdout.columns is `undefined` when
151
+ // stdout is a pipe, so falling back to env keeps tables responsive
152
+ // for piped consumers like /ui dashboards). Final fallback: 100.
153
+ const envCols = process.env.COLUMNS ? parseInt(process.env.COLUMNS, 10) : 0;
154
+ const maxWidth = opts.maxWidth ??
155
+ process.stdout.columns ??
156
+ (envCols > 0 ? envCols : 100);
157
+ const availableForContent = Math.max(numCols * 8, maxWidth - overhead);
158
+ const widths = allocateWidths(cols, naturalWidths, availableForContent);
159
+ // Apply smart truncation to any cell whose content exceeds its
160
+ // allocated width. Non-flex columns at natural width never trigger
161
+ // this branch; flex columns may.
162
+ for (let i = 0; i < numCols; i += 1) {
163
+ const w = widths[i];
164
+ for (const rowVals of valueGrid) {
165
+ if (vWidth(rowVals[i]) > w) {
166
+ rowVals[i] = smartTrunc(rowVals[i], w);
167
+ }
168
+ }
169
+ }
170
+ // Border characters (sharp ASCII).
171
+ const TL = '┌', TR = '┐', BL = '└', BR = '┘';
172
+ const T = '┬', B = '┴', L = '├', R = '┤';
173
+ const X = '┼', H = '─', V = '│';
174
+ const ind = ' '.repeat(indent);
175
+ // Top border.
176
+ const top = TL + widths.map((w) => H.repeat(w + 2)).join(T) + TR;
177
+ // Header row — heading colour, padded. Truncate first if the
178
+ // header itself is wider than the allocated width (rare, but
179
+ // keeps borders aligned under aggressive narrow-width pressure).
180
+ const headerCells = cols.map((c, i) => {
181
+ const w = widths[i];
182
+ const text = vWidth(c.header) > w ? smartTrunc(c.header, w) : c.header;
183
+ const padded = pad(text, w, c.align ?? 'left');
184
+ return ' ' + skin.applyColors(padded, 'heading') + ' ';
185
+ });
186
+ const headerRow = V + headerCells.join(V) + V;
187
+ // Header rule.
188
+ const rule = L + widths.map((w) => H.repeat(w + 2)).join(X) + R;
189
+ // Body rows.
190
+ const bodyLines = [];
191
+ const compact = opts.compact === true;
192
+ valueGrid.forEach((rowVals, rIdx) => {
193
+ if (!compact && rIdx > 0) {
194
+ // Tier-3.1a: inter-row separator using `├─…─┼─…─┤` glyphs.
195
+ bodyLines.push(L + widths.map((w) => H.repeat(w + 2)).join(X) + R);
196
+ }
197
+ const cells = cols.map((c, i) => {
198
+ const raw = rowVals[i];
199
+ const padded = pad(raw, widths[i], c.align ?? 'left');
200
+ const colorKind = c.color ? c.color(rows[rIdx][c.key], rows[rIdx]) : undefined;
201
+ const painted = colorKind ? skin.applyColors(padded, colorKind) : padded;
202
+ return ' ' + painted + ' ';
203
+ });
204
+ bodyLines.push(V + cells.join(V) + V);
205
+ });
206
+ // Bottom border.
207
+ const bot = BL + widths.map((w) => H.repeat(w + 2)).join(B) + BR;
208
+ const allLines = [
209
+ top,
210
+ headerRow,
211
+ ...(showRule ? [rule] : []),
212
+ ...bodyLines,
213
+ bot,
214
+ ].map((l) => ind + l);
215
+ return allLines.join('\n') + '\n';
216
+ }
@@ -0,0 +1,81 @@
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/themeDetect.ts — Phase v4.1-tier3-essentials
10
+ *
11
+ * Multi-signal auto theme detection. The skin engine consults this
12
+ * once at boot when the configured skin is `auto`; otherwise the
13
+ * explicitly-named skin wins.
14
+ *
15
+ * Priority order (first non-undefined hit wins):
16
+ *
17
+ * 1. AIDEN_THEME=light|dark — explicit override
18
+ * 2. AIDEN_THEME=auto / unset goes through 2..5
19
+ * 3. NO_COLOR set — forced monochrome
20
+ * 4. COLORFGBG="<fg>;<bg>" — slot 7 or 15 = light, others = dark
21
+ * 5. TERM_PROGRAM allow-list — Apple_Terminal default to light
22
+ * 6. Fallback: dark
23
+ *
24
+ * Returns 'light' / 'dark' / 'mono'. The skin engine maps:
25
+ * 'mono' → monochrome skin (no colour)
26
+ * 'light' → light skin
27
+ * 'dark' → default skin
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.detectTheme = detectTheme;
31
+ exports.detectedToSkinName = detectedToSkinName;
32
+ const LIGHT_DEFAULT_TERM_PROGRAMS = new Set([
33
+ // Apple Terminal default profile is on a light background.
34
+ 'Apple_Terminal',
35
+ ]);
36
+ /**
37
+ * Run the multi-signal detection. Pure function — env can be
38
+ * overridden for tests.
39
+ */
40
+ function detectTheme(env = process.env) {
41
+ const explicit = (env.AIDEN_THEME ?? '').trim().toLowerCase();
42
+ if (explicit === 'light')
43
+ return 'light';
44
+ if (explicit === 'dark')
45
+ return 'dark';
46
+ if (explicit === 'mono' || explicit === 'monochrome')
47
+ return 'mono';
48
+ // NO_COLOR (https://no-color.org) — monochrome is its own theme,
49
+ // independent of light/dark, so it wins over the auto path.
50
+ if (env.NO_COLOR != null && env.NO_COLOR !== '')
51
+ return 'mono';
52
+ // COLORFGBG = "<fg>;<bg>" where slot 7 (light grey) or 15 (white)
53
+ // signals a light terminal background. Other slots = dark.
54
+ const colorfgbg = (env.COLORFGBG ?? '').trim();
55
+ if (colorfgbg) {
56
+ const parts = colorfgbg.split(';');
57
+ const lastField = parts[parts.length - 1] ?? '';
58
+ if (/^\d+$/.test(lastField)) {
59
+ const bg = Number(lastField);
60
+ if (bg === 7 || bg === 15)
61
+ return 'light';
62
+ if (bg >= 0 && bg < 16)
63
+ return 'dark';
64
+ }
65
+ }
66
+ // TERM_PROGRAM allow-list.
67
+ const termProgram = (env.TERM_PROGRAM ?? '').trim();
68
+ if (LIGHT_DEFAULT_TERM_PROGRAMS.has(termProgram))
69
+ return 'light';
70
+ // Fallback.
71
+ return 'dark';
72
+ }
73
+ /** Surfaced for skinEngine integration: maps DetectedTheme → skin name. */
74
+ function detectedToSkinName(theme) {
75
+ switch (theme) {
76
+ case 'light': return 'light';
77
+ case 'mono': return 'monochrome';
78
+ case 'dark':
79
+ default: return 'default';
80
+ }
81
+ }
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AIDEN_PRESHIP_BUILD = exports.AIDEN_CROSS_PLATFORM_BUILD = exports.AIDEN_REPLY_FORMAT_BUILD = exports.AIDEN_SKILL_MINING_BUILD = exports.AIDEN_UI_BUILD = void 0;
4
+ exports.citationsEnabled = citationsEnabled;
5
+ exports.isMcpServeMode = isMcpServeMode;
6
+ exports.isNoUiMode = isNoUiMode;
7
+ exports.uiIconsEnabled = uiIconsEnabled;
8
+ /**
9
+ * Copyright (c) 2026 Shiva Deore (Taracod). Licensed under AGPL-3.0.
10
+ *
11
+ * cli/v4/uiBuild.ts — Aiden v4.1 Tier-3 UI build fingerprint.
12
+ *
13
+ * A single source-of-truth string the smokes can `require()` from
14
+ * the built artifact. Bumped by hand at the start of each tier-3
15
+ * sub-phase so smoke harnesses can pin against the expected build.
16
+ */
17
+ exports.AIDEN_UI_BUILD = 'v4.1-tier3-essentials';
18
+ /**
19
+ * Phase v4.1-skill-mining: build fingerprint for the auto-extract
20
+ * subsystem. Bumped per skill-mining sub-phase so smokes can pin
21
+ * against the expected build.
22
+ */
23
+ exports.AIDEN_SKILL_MINING_BUILD = 'v4.1-skill-mining';
24
+ /**
25
+ * Phase v4.1-reply-formatting: build fingerprint for the structured
26
+ * markdown rendering / citation footer / streaming stable-prefix
27
+ * subsystem. Render-layer only — no agent prompts or behavior change.
28
+ */
29
+ exports.AIDEN_REPLY_FORMAT_BUILD = 'v4.1-reply-formatting';
30
+ /**
31
+ * Phase v4.1-cross-platform: build fingerprint for the Linux / macOS
32
+ * compatibility pass — path helpers, audio backend detection, skill
33
+ * loader case-insensitive lookup, doctor checks per OS, CI matrix.
34
+ */
35
+ exports.AIDEN_CROSS_PLATFORM_BUILD = 'v4.1-cross-platform';
36
+ /**
37
+ * Phase v4.1-preship-cleanup: build fingerprint for the day-one
38
+ * polish batch — vitest baseline goes from 37 fails to 0, telegram
39
+ * 409 path gains a local-machine polling lock to prevent same-box
40
+ * rivals from racing.
41
+ */
42
+ exports.AIDEN_PRESHIP_BUILD = 'v4.1-preship-cleanup';
43
+ /** Predicate: is the citation footer enabled? Default off. */
44
+ function citationsEnabled() {
45
+ return process.env.AIDEN_CITATIONS === '1';
46
+ }
47
+ /**
48
+ * Predicate: are we running in MCP serve mode? When true, the
49
+ * stdout channel belongs to JSON-RPC and any UI write would corrupt
50
+ * the wire. Tier-3 UI helpers consult this before printing.
51
+ *
52
+ * The MCP server CLI (cli/v4/commands/mcp.ts) sets
53
+ * `process.env.AIDEN_MCP_SERVE = '1'` early in its boot path; that
54
+ * env-var check is intentionally cheap and safe to read often.
55
+ */
56
+ function isMcpServeMode() {
57
+ return process.env.AIDEN_MCP_SERVE === '1';
58
+ }
59
+ /**
60
+ * Predicate: is the legacy/no-UI flag in effect? Disables tier-3
61
+ * polish (autosuggest ghost text, inline status line, etc.) and
62
+ * falls back to pre-tier3.1 rendering. Set by `aiden --no-ui`.
63
+ */
64
+ function isNoUiMode() {
65
+ return process.env.AIDEN_NO_UI === '1';
66
+ }
67
+ /**
68
+ * Predicate: should slash-command icons render? Default OFF; opt-in
69
+ * via `AIDEN_UI_ICONS=1`. Lets users with emoji-friendly terminals
70
+ * recover the previous icon column.
71
+ */
72
+ function uiIconsEnabled() {
73
+ return process.env.AIDEN_UI_ICONS === '1';
74
+ }
@@ -0,0 +1,113 @@
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/voiceCli.ts — Phase v4.1-voice-cli
10
+ *
11
+ * `aiden voice <action>` top-level CLI subcommand. Three actions:
12
+ *
13
+ * doctor — print diagnostics: build, TTY, mic backend,
14
+ * TTS providers, current config. No mic open.
15
+ * tts <text> — synthesise + play one short clip. Real
16
+ * provider call.
17
+ * transcribe <f> — STT one audio file. Reuses the v4.1-3
18
+ * `whisper-transcribe` channel pipeline.
19
+ *
20
+ * Distinct from the `/voice` slash command (which mutates session
21
+ * state from inside the REPL). This subcommand exists so users can
22
+ * verify mic + speaker setup BEFORE entering the REPL — useful for
23
+ * first-run mic-permission grants on Windows where the OS prompts
24
+ * the first time the device is opened.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.AIDEN_VOICE_CLI_BUILD = void 0;
28
+ exports.runVoiceSubcommand = runVoiceSubcommand;
29
+ /* eslint-disable @typescript-eslint/no-explicit-any */
30
+ const node_fs_1 = require("node:fs");
31
+ const diagnostics_1 = require("../../core/v4/voice/diagnostics");
32
+ Object.defineProperty(exports, "AIDEN_VOICE_CLI_BUILD", { enumerable: true, get: function () { return diagnostics_1.AIDEN_VOICE_CLI_BUILD; } });
33
+ const tts_1 = require("../../core/voice/tts");
34
+ const whisper_transcribe_1 = require("../../core/channels/whisper-transcribe");
35
+ async function runVoiceSubcommand(action, args, opts = {}) {
36
+ const writeOut = opts.writeOut ?? ((t) => process.stdout.write(t));
37
+ const writeErr = opts.writeErr ?? ((t) => process.stderr.write(t));
38
+ switch (action) {
39
+ case 'doctor': {
40
+ const diag = await (0, diagnostics_1.collectVoiceDiagnostics)();
41
+ writeOut(`Aiden voice — ${diagnostics_1.AIDEN_VOICE_CLI_BUILD}\n`);
42
+ writeOut(` tty: ${diag.isTty ? 'yes' : 'no'}\n`);
43
+ writeOut(` enabled: ${diag.enabled ? 'yes' : 'no (refused — non-TTY stdin)'}\n`);
44
+ writeOut(` mic backend: ${diag.audio.backend}\n`);
45
+ writeOut(` mic active: ${diag.audio.active ? 'yes' : 'no'}\n`);
46
+ writeOut(` sox on PATH: ${diag.audio.soxOnPath ? 'yes' : 'no'}\n`);
47
+ writeOut(` mode: ${diag.config.mode}\n`);
48
+ writeOut(` tts voice: ${diag.config.ttsVoice}\n`);
49
+ writeOut(` beeps: ${diag.config.beepsEnabled ? 'on' : 'off'}\n`);
50
+ writeOut(` tts providers:\n`);
51
+ for (const p of diag.ttsProviders) {
52
+ const tag = p.available ? '✓' : '✗';
53
+ writeOut(` ${tag} ${p.name.padEnd(12)} ${p.note ?? ''}\n`);
54
+ }
55
+ // Mic-backend hint when nothing is installed.
56
+ if (diag.audio.backend === 'unavailable') {
57
+ writeOut(`\n Hint: install \`decibri\` (npm i decibri) for prebuilt mic capture,\n`);
58
+ writeOut(` OR install sox (https://sox.sourceforge.io/) + node-record-lpcm16.\n`);
59
+ }
60
+ return 0;
61
+ }
62
+ case 'tts': {
63
+ const text = args.join(' ').trim();
64
+ if (!text) {
65
+ writeErr(`Usage: aiden voice tts "<text>"\n`);
66
+ return 1;
67
+ }
68
+ const cleaned = (0, tts_1.cleanForTTS)(text);
69
+ if (!cleaned) {
70
+ writeErr(`Empty after cleanForTTS — nothing to speak.\n`);
71
+ return 1;
72
+ }
73
+ writeOut(`Synthesising via TTS chain (${cleaned.length} chars)...\n`);
74
+ const r = await (0, tts_1.synthesize)({ text: cleaned });
75
+ if (r.error) {
76
+ writeErr(`TTS failed: ${r.error}\n`);
77
+ return 1;
78
+ }
79
+ writeOut(`TTS ok — provider: ${r.provider}, ${r.durationMs}ms\n`);
80
+ return 0;
81
+ }
82
+ case 'transcribe': {
83
+ const filePath = args[0];
84
+ if (!filePath) {
85
+ writeErr(`Usage: aiden voice transcribe <audio-file>\n`);
86
+ return 1;
87
+ }
88
+ try {
89
+ await node_fs_1.promises.access(filePath);
90
+ }
91
+ catch {
92
+ writeErr(`File not found: ${filePath}\n`);
93
+ return 1;
94
+ }
95
+ writeOut(`Transcribing ${filePath}...\n`);
96
+ const r = await (0, whisper_transcribe_1.transcribeForChannel)({ filePath });
97
+ if (!r.success) {
98
+ writeErr(`Transcribe failed: ${r.error ?? 'unknown'}\n`);
99
+ return 1;
100
+ }
101
+ const conf = typeof r.avgLogprob === 'number'
102
+ ? ` (avgLogprob=${r.avgLogprob.toFixed(2)})`
103
+ : '';
104
+ writeOut(`Transcript${conf}:\n${r.text ?? ''}\n`);
105
+ return 0;
106
+ }
107
+ default: {
108
+ writeErr(`Unknown 'aiden voice' action: ${action}\n`);
109
+ writeErr(`Actions: doctor | tts <text> | transcribe <file>\n`);
110
+ return 1;
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,196 @@
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/voicePromptApi.ts — Phase v4.1-voice-cli
10
+ *
11
+ * Wraps a default `ChatPromptApi` implementation with a raw-mode
12
+ * spacebar toggle for push-to-talk recording. When the user is at
13
+ * the prompt and presses Space:
14
+ *
15
+ * 1. Switch to raw mode + start `cliVoice.startRecording()`
16
+ * 2. Update the spinner: "🎤 recording (Space to stop, Esc to cancel)"
17
+ * 3. On second Space: stop and transcribe → return transcript
18
+ * 4. On Esc: cancel and return empty (caller falls back to text)
19
+ * 5. On any other character before the first Space: hand control
20
+ * back to the wrapped `inquirer` prompt so the user types
21
+ * normally
22
+ *
23
+ * Hard-refuses activation when `process.stdin.isTTY` is false. This
24
+ * is the MCP-stdio invariant — `aiden mcp serve` uses stdin as the
25
+ * JSON-RPC transport, and toggling raw mode there would corrupt
26
+ * every protocol frame. The refusal is silent in MCP context (the
27
+ * default `readLine` runs unchanged); explicit in REPL context (a
28
+ * stderr warning + fall-through).
29
+ *
30
+ * `selectSlashCommand` is delegated unchanged — slash commands
31
+ * still go through the inquirer dropdown.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.createVoicePromptApi = createVoicePromptApi;
35
+ exports.voiceModeAllowed = voiceModeAllowed;
36
+ const factory_1 = require("../../core/v4/logger/factory");
37
+ const KEY_SPACE = 0x20;
38
+ const KEY_ESC = 0x1b;
39
+ /** Build a prompt API that intercepts Space for push-to-talk and
40
+ * falls through to `inner` for normal text input. */
41
+ function createVoicePromptApi(opts) {
42
+ const logger = (opts.logger ?? (0, factory_1.noopLogger)()).child('voice-prompt');
43
+ const stdin = opts.stdin ?? process.stdin;
44
+ const stdout = opts.stdout ?? process.stdout;
45
+ return {
46
+ async readLine(prompt) {
47
+ // Hard refuse when stdin isn't a TTY. Voice mode requires raw
48
+ // mode; raw mode requires a TTY. MCP stdio mode hits this path
49
+ // when Claude Desktop spawns aiden — silently fall through.
50
+ if (!stdin.isTTY) {
51
+ return opts.inner.readLine(prompt);
52
+ }
53
+ const transcript = await waitForSpaceOrTypedInput({
54
+ prompt,
55
+ stdin,
56
+ stdout,
57
+ voice: opts.voice,
58
+ onStatus: opts.onStatus,
59
+ logger,
60
+ });
61
+ if (transcript === null) {
62
+ // User typed text — hand off to the regular prompt API. The
63
+ // first character is already in the typeahead via the buffer
64
+ // — `inner.readLine` reads from there.
65
+ return opts.inner.readLine(prompt);
66
+ }
67
+ if (transcript === '') {
68
+ // Cancelled — fall back to text prompt.
69
+ return opts.inner.readLine(prompt);
70
+ }
71
+ return transcript;
72
+ },
73
+ async selectSlashCommand(source) {
74
+ // Slash commands don't get voice intercept — they're a
75
+ // discrete dropdown.
76
+ return opts.inner.selectSlashCommand(source);
77
+ },
78
+ };
79
+ }
80
+ /** Wait for either Space (start recording) or any other char (fall
81
+ * through to text prompt). Returns:
82
+ * - the transcribed string when recording completes
83
+ * - '' when user cancels (Esc)
84
+ * - null when user types non-space (fall through to inner) */
85
+ async function waitForSpaceOrTypedInput(args) {
86
+ // Show a brief hint so users know voice mode is hot.
87
+ args.stdout.write(`${args.prompt}\x1b[2m(Space to talk)\x1b[0m `);
88
+ const stdin = args.stdin;
89
+ // Snapshot current raw mode state to restore on exit.
90
+ const wasRaw = !!stdin.isRaw;
91
+ if (!wasRaw)
92
+ stdin.setRawMode(true);
93
+ stdin.resume();
94
+ let result = undefined;
95
+ let recording = false;
96
+ let transcript = null;
97
+ let resolveOuter = null;
98
+ const cleanup = () => {
99
+ stdin.removeListener('data', onData);
100
+ stdin.removeListener('error', onError);
101
+ if (!wasRaw) {
102
+ try {
103
+ stdin.setRawMode(false);
104
+ }
105
+ catch { /* ignore */ }
106
+ }
107
+ stdin.pause();
108
+ };
109
+ const onData = (chunk) => {
110
+ if (chunk.length === 0)
111
+ return;
112
+ const code = chunk[0];
113
+ if (!recording) {
114
+ if (code === KEY_SPACE) {
115
+ // Start recording.
116
+ recording = true;
117
+ args.voice.startRecording().catch((err) => {
118
+ args.logger.warn('startRecording threw', { error: err.message });
119
+ });
120
+ }
121
+ else if (code === KEY_ESC) {
122
+ result = '';
123
+ cleanup();
124
+ resolveOuter?.('');
125
+ }
126
+ else if (code === 0x03) {
127
+ // Ctrl+C — propagate to inner via empty cancel.
128
+ result = '';
129
+ cleanup();
130
+ resolveOuter?.('');
131
+ }
132
+ else {
133
+ // Any other key — fall through to inner prompt. Push the
134
+ // byte back so inner reads it (best-effort; on Windows
135
+ // the unread() trick isn't reliable, so we just signal
136
+ // null and inner re-prompts).
137
+ result = null;
138
+ cleanup();
139
+ resolveOuter?.(null);
140
+ }
141
+ }
142
+ else {
143
+ // Already recording. Space stops; Esc cancels.
144
+ if (code === KEY_SPACE) {
145
+ args.voice.stopRecording().catch((err) => {
146
+ args.logger.warn('stopRecording threw', { error: err.message });
147
+ });
148
+ }
149
+ else if (code === KEY_ESC || code === 0x03) {
150
+ args.voice.cancel();
151
+ result = '';
152
+ cleanup();
153
+ resolveOuter?.('');
154
+ }
155
+ }
156
+ };
157
+ const onError = (err) => {
158
+ args.logger.warn('stdin error during voice prompt', { error: err.message });
159
+ cleanup();
160
+ resolveOuter?.(null);
161
+ };
162
+ // Voice handle's onTranscript wins the race when recording succeeds.
163
+ // We register a one-shot subscription via the existing callback by
164
+ // taking advantage of the fact that handle.startRecording resolves
165
+ // when transcribe completes — at that point transcript will be set
166
+ // through the host's status callback. To keep this module narrowly
167
+ // scoped, we POLL voice.getStatus() between awaits via a watcher.
168
+ const watcher = setInterval(() => {
169
+ const s = args.voice.getStatus();
170
+ args.onStatus?.(s);
171
+ // Recording finished naturally OR errored.
172
+ if (recording && s === 'idle') {
173
+ // Drain stdin and resolve. The transcript was forwarded via
174
+ // the host's onTranscript callback (set up in the cliVoice
175
+ // constructor); the host stitches it into the conversation.
176
+ // For the prompt-API contract we resolve with empty so the
177
+ // outer loop spins to the next iteration.
178
+ cleanup();
179
+ clearInterval(watcher);
180
+ resolveOuter?.(transcript ?? '');
181
+ }
182
+ }, 50);
183
+ stdin.on('data', onData);
184
+ stdin.on('error', onError);
185
+ return new Promise((resolve) => {
186
+ resolveOuter = (v) => {
187
+ clearInterval(watcher);
188
+ resolve(v);
189
+ };
190
+ });
191
+ }
192
+ /** Test-only helper: enforce the TTY guard. Returns true when voice
193
+ * mode is allowed to activate in this process. */
194
+ function voiceModeAllowed(stdin = process.stdin) {
195
+ return !!stdin.isTTY;
196
+ }