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
@@ -20,6 +20,10 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
22
22
  exports.iconForTool = iconForTool;
23
+ exports.detectConfiguredChannels = detectConfiguredChannels;
24
+ exports.summarizeConfiguredChannels = summarizeConfiguredChannels;
25
+ exports.summarizeChannelState = summarizeChannelState;
26
+ exports.voiceIndicator = voiceIndicator;
23
27
  exports.previewToolArgs = previewToolArgs;
24
28
  exports.formatToolDuration = formatToolDuration;
25
29
  exports.formatCompactTokens = formatCompactTokens;
@@ -32,6 +36,12 @@ const marked_1 = require("marked");
32
36
  const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
33
37
  const skinEngine_1 = require("./skinEngine");
34
38
  const box_1 = require("./box");
39
+ // Phase v4.1-reply-formatting: skin-aware markdown renderer that
40
+ // replaces marked-terminal's defaults with structured headers, lists,
41
+ // code blocks, blockquotes, and links.
42
+ const replyRenderer_1 = require("./replyRenderer");
43
+ // Optional "Sources" footer when AIDEN_CITATIONS=1 (default off).
44
+ const citationFooter_1 = require("./citationFooter");
35
45
  /**
36
46
  * Phase 26.2.7 — category emoji icons for the tool-row prefix when
37
47
  * `AIDEN_UI_ICONS=1` is set in the environment. Default OFF (the
@@ -126,6 +136,74 @@ exports.SPINNER_PHRASES = [
126
136
  'Smelting',
127
137
  'Conjuring',
128
138
  ];
139
+ /**
140
+ * Phase v4.1-1 — boot card "channels" pill helpers. The CLI process
141
+ * doesn't actually run channel adapters (those live in the API server)
142
+ * so we report what we *can* honestly know from the environment: how
143
+ * many of the nine channel adapters have their credentials present.
144
+ *
145
+ * `summarizeConfiguredChannels` returns a render-ready label like
146
+ * `"3 configured (incl. telegram)"` for the Environment column.
147
+ */
148
+ const CHANNEL_ENV_VARS = [
149
+ { id: 'telegram', vars: ['TELEGRAM_BOT_TOKEN'] },
150
+ { id: 'discord', vars: ['DISCORD_BOT_TOKEN'] },
151
+ { id: 'slack', vars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'] },
152
+ { id: 'whatsapp', vars: ['WHATSAPP_BUSINESS_API_KEY'] },
153
+ { id: 'twilio', vars: ['TWILIO_AUTH_TOKEN'] },
154
+ { id: 'imessage', vars: ['BLUEBUBBLES_PASSWORD'] },
155
+ { id: 'email', vars: ['EMAIL_IMAP_PASSWORD', 'EMAIL_SMTP_PASSWORD'] },
156
+ ];
157
+ function detectConfiguredChannels(env = process.env) {
158
+ const ids = [];
159
+ for (const c of CHANNEL_ENV_VARS) {
160
+ if (c.vars.some((v) => typeof env[v] === 'string' && env[v].trim() !== '')) {
161
+ ids.push(c.id);
162
+ }
163
+ }
164
+ return { total: ids.length, ids, telegram: ids.includes('telegram') };
165
+ }
166
+ function summarizeConfiguredChannels(detection = detectConfiguredChannels()) {
167
+ if (detection.total === 0)
168
+ return '0 configured';
169
+ const suffix = detection.telegram ? ' (incl. telegram)' : '';
170
+ return `${detection.total} configured${suffix}`;
171
+ }
172
+ function summarizeChannelState(probe, envFallback = detectConfiguredChannels()) {
173
+ // No live probe (e.g. test harness with no manager) → env-only count.
174
+ if (!probe) {
175
+ if (envFallback.total === 0) {
176
+ return '0 configured (run /channel telegram add to enable)';
177
+ }
178
+ return `${envFallback.total} configured${envFallback.telegram ? ' (incl. telegram)' : ''}`;
179
+ }
180
+ // Phase v4.1-1.2 — conflict takes priority over the generic active /
181
+ // offline split. If any adapter is in the conflict state, surface
182
+ // that explicitly so the user has an unambiguous remediation hint.
183
+ const conflicted = probe.adapters.filter((a) => a.state === 'conflict');
184
+ if (conflicted.length > 0) {
185
+ const names = conflicted.map((a) => a.id).join(', ');
186
+ return `${conflicted.length} degraded: ${names} (conflict — /channel telegram takeover)`;
187
+ }
188
+ const active = probe.adapters.filter((a) => a.healthy);
189
+ const inactive = probe.adapters.filter((a) => !a.healthy);
190
+ if (active.length === 0) {
191
+ if (envFallback.total === 0) {
192
+ return '0 configured (run /channel telegram add to enable)';
193
+ }
194
+ // Token in env but adapter not healthy — frame it as offline so
195
+ // the user knows /channel telegram status is the next step.
196
+ const offlineNames = inactive
197
+ .filter((a) => envFallback.ids.includes(a.id))
198
+ .map((a) => a.id)
199
+ .join(', ');
200
+ return offlineNames
201
+ ? `${envFallback.total} configured: ${offlineNames} (offline — /channel telegram status)`
202
+ : `${envFallback.total} configured`;
203
+ }
204
+ const parts = active.map((a) => (a.botHandle ? `${a.id} (@${a.botHandle})` : a.id));
205
+ return `${active.length} active: ${parts.join(', ')}`;
206
+ }
129
207
  const AIDEN_BANNER = String.raw `
130
208
  █████╗ ██╗██████╗ ███████╗███╗ ██╗
131
209
  ██╔══██╗██║██╔══██╗██╔════╝████╗ ██║
@@ -144,6 +222,14 @@ class Display {
144
222
  // starts on its own line.
145
223
  this.streamHeaderShown = false;
146
224
  this.streamLastEndedNewline = false;
225
+ // Phase v4.1-reply-formatting: track the running buffered stream
226
+ // so streamComplete can re-render it as structured markdown
227
+ // (headers / lists / code blocks / blockquotes) once the full
228
+ // body is known. During streaming the raw text remains visible
229
+ // — the post-stream pass clears it via cursor-up + erase-line and
230
+ // reprints the formatted output.
231
+ this.streamBuffer = '';
232
+ this.streamLineCount = 0;
147
233
  this.skin = opts.skin ?? (0, skinEngine_1.getSkinEngine)();
148
234
  this.out = opts.stdout ?? process.stdout;
149
235
  this.err = opts.stderr ?? process.stderr;
@@ -305,11 +391,13 @@ class Display {
305
391
  const lab = (s) => sk.applyColors(s, 'muted');
306
392
  const val = (s) => sk.applyColors(s, 'agent');
307
393
  const pill = (on, label, value) => `${dot(on)} ${lab(label)} ${val(value)}`;
394
+ const providerOk = args.providerOk !== false;
395
+ const modelValue = providerOk ? args.model : 'not configured';
308
396
  return (' ' +
309
397
  [
310
398
  pill(args.coreOnline, 'core', args.coreOnline ? 'online' : 'starting'),
311
399
  pill(true, 'mode', args.mode),
312
- pill(true, 'model', args.model),
400
+ pill(providerOk, 'model', modelValue),
313
401
  pill(args.memoryActive, 'memory', args.memoryActive ? 'active' : 'off'),
314
402
  ].join(' '));
315
403
  }
@@ -318,10 +406,13 @@ class Display {
318
406
  * `cols() >= 80`, stacked vertically below that. Title in brand,
319
407
  * keys in muted (padded to 11 visible chars), values in `agent`.
320
408
  */
321
- twoColumnBlock(left, right) {
409
+ twoColumnBlock(left, right, opts = {}) {
322
410
  const sk = this.skin;
323
411
  const cols = this.cols();
324
- const stacked = cols < 80;
412
+ // Tier-3.1b: callers can raise the side-by-side threshold (boot
413
+ // card prefers stacked at 70-119 cols, side-by-side only at ≥120).
414
+ // Default 80 preserves prior behaviour for any other caller.
415
+ const stacked = cols < (opts.sideBySideThreshold ?? 80);
325
416
  const indent = ' ';
326
417
  const KEY_PAD = 11;
327
418
  const renderRows = (sec) => {
@@ -393,13 +484,10 @@ class Display {
393
484
  const val = (s) => sk.applyColors(s, 'agent');
394
485
  const heart = sk.applyColors('♥', 'brand');
395
486
  if (this.cols() < 80) {
396
- // Plain 4-line fallback (no border).
397
- return [
398
- ` ${heart} ${val('Built solo')}`,
399
- ` ${lab('GitHub:')} ${val('github.com/taracodlabs/aiden')}`,
400
- ` ${lab('Web:')} ${val('aiden.taracod.com')}`,
401
- ` ${lab('Contact:')} ${val('contact@taracod.com')}`,
402
- ].join('\n');
487
+ // Tier-3.1b: single-line credits at narrow widths so the boot
488
+ // card stays compact. The 4-line plain fallback shipped earlier
489
+ // wastes vertical space when terminals already squeeze content.
490
+ return ` ${heart} ${m('built solo · github.com/taracodlabs/aiden · aiden.taracod.com')}`;
403
491
  }
404
492
  // Parchment.
405
493
  const INTERIOR = 63;
@@ -475,6 +563,74 @@ class Display {
475
563
  const elapsed = sk.applyColors(formatElapsedShort(args.elapsedMs), 'muted');
476
564
  return ` ${provModel}${SEP}${ctxSeg}${SEP}${elapsed}`;
477
565
  }
566
+ /**
567
+ * Tier-3.1 (v4.1-tier3.1): pre-prompt status line.
568
+ *
569
+ * Single-line summary written ABOVE the input prompt on every
570
+ * fresh turn. Format (full, ≥76 cols):
571
+ *
572
+ * <provider>:<model> · ctx <N>/<M>k · MCP <state> · cron <state>
573
+ *
574
+ * Width-tier degrade:
575
+ * <52 cols → only `<provider>:<model> · ctx N/Mk`
576
+ * <76 cols → drop voice indicator
577
+ * ≥76 cols → full
578
+ *
579
+ * MCP serve mode: this helper is a pure builder; callers MUST
580
+ * gate the actual write on `isMcpServeMode()` from
581
+ * `cli/v4/uiBuild.ts`. The function itself never writes.
582
+ */
583
+ renderStatusLine(args) {
584
+ const sk = this.skin;
585
+ const cols = args.cols ?? this.cols();
586
+ const SEP = sk.applyColors(' · ', 'muted');
587
+ const colourForState = (s) => {
588
+ if (s === 'active')
589
+ return 'success';
590
+ if (s === 'broken')
591
+ return 'error';
592
+ return 'muted';
593
+ };
594
+ const glyphForState = (s) => {
595
+ if (s === 'active')
596
+ return '✓'; // ✓
597
+ if (s === 'broken')
598
+ return '✗'; // ✗
599
+ return '-';
600
+ };
601
+ const provModel = sk.applyColors(args.provider, 'muted') +
602
+ sk.applyColors(':', 'muted') +
603
+ sk.applyColors(args.model, 'agent');
604
+ const ctxSeg = (() => {
605
+ if (args.ctxMax == null || args.ctxUsed == null)
606
+ return '';
607
+ const usedK = Math.round(args.ctxUsed / 1000);
608
+ const maxK = Math.max(1, Math.round(args.ctxMax / 1000));
609
+ return sk.applyColors(`ctx ${usedK}/${maxK}k`, 'muted');
610
+ })();
611
+ const mcpSeg = args.mcpState
612
+ ? sk.applyColors(`MCP ${glyphForState(args.mcpState)}`, colourForState(args.mcpState))
613
+ : '';
614
+ const cronSeg = args.cronState
615
+ ? sk.applyColors(`cron ${glyphForState(args.cronState)}`, colourForState(args.cronState))
616
+ : '';
617
+ const voiceSeg = args.voiceRecording
618
+ ? sk.applyColors('[REC]', 'error')
619
+ : '';
620
+ // Compose with width-tier degrade.
621
+ const segs = [provModel];
622
+ if (ctxSeg)
623
+ segs.push(ctxSeg);
624
+ if (cols >= 52) {
625
+ if (mcpSeg)
626
+ segs.push(mcpSeg);
627
+ if (cronSeg)
628
+ segs.push(cronSeg);
629
+ }
630
+ if (cols >= 76 && voiceSeg)
631
+ segs.push(voiceSeg);
632
+ return ' ' + segs.join(SEP);
633
+ }
478
634
  /**
479
635
  * Optional provider-switch indicator line — emitted only when this
480
636
  * turn ran on a different provider than the previous one. Format
@@ -655,14 +811,24 @@ class Display {
655
811
  const arrow = sk.getActive().glyphs?.arrow ?? '>';
656
812
  return `${sk.applyColors(arrow, 'tool')} ${sk.applyColors(name, 'tool')} ${sk.applyColors(serialized, 'muted')}`;
657
813
  }
658
- /** Render markdown to ANSI; falls back to raw text if marked-terminal failed to wire. */
814
+ /**
815
+ * Render markdown to ANSI via the v4.1-reply-formatting renderer
816
+ * (skin-aware headers / lists / code / quotes / links). Falls back
817
+ * to the marked-terminal default config if the structured renderer
818
+ * is unavailable, then raw text as a last resort.
819
+ */
659
820
  markdown(text) {
660
821
  try {
661
- const out = marked_1.marked.parse(text);
662
- return typeof out === 'string' ? out : String(out);
822
+ return (0, replyRenderer_1.getReplyRenderer)().render(text);
663
823
  }
664
824
  catch {
665
- return text;
825
+ try {
826
+ const out = marked_1.marked.parse(text);
827
+ return typeof out === 'string' ? out : String(out);
828
+ }
829
+ catch {
830
+ return text;
831
+ }
666
832
  }
667
833
  }
668
834
  /** Format a user turn (e.g. echoed back from history). */
@@ -807,9 +973,18 @@ class Display {
807
973
  // open identically.
808
974
  this.out.write(this.agentHeader());
809
975
  this.streamHeaderShown = true;
976
+ this.streamBuffer = '';
977
+ this.streamLineCount = 0;
810
978
  }
811
979
  this.out.write(text);
812
980
  this.streamLastEndedNewline = text.endsWith('\n');
981
+ // Phase v4.1-reply-formatting: track buffer + line count for the
982
+ // post-stream re-render. We count newlines in the OUTGOING bytes
983
+ // so the eraser later knows how many rows to clear.
984
+ this.streamBuffer += text;
985
+ for (let i = 0; i < text.length; i += 1)
986
+ if (text[i] === '\n')
987
+ this.streamLineCount += 1;
813
988
  }
814
989
  /**
815
990
  * Mark the end of a streaming turn. Adds a trailing newline if the
@@ -822,8 +997,68 @@ class Display {
822
997
  return;
823
998
  if (!this.streamLastEndedNewline)
824
999
  this.out.write('\n');
1000
+ // Phase v4.1-reply-formatting: re-render the buffered stream as
1001
+ // structured markdown — but ONLY when stdout is a TTY and the
1002
+ // buffer actually contains markdown structure worth rendering.
1003
+ // Plain prose with no headers / lists / fences gets left alone
1004
+ // (no flicker, identical output). Otherwise we erase the raw
1005
+ // streamed body via cursor-up + erase-line and reprint via the
1006
+ // skin-aware renderer.
1007
+ const buffered = this.streamBuffer;
1008
+ const lines = this.streamLineCount;
1009
+ this.streamBuffer = '';
1010
+ this.streamLineCount = 0;
825
1011
  this.streamHeaderShown = false;
826
1012
  this.streamLastEndedNewline = false;
1013
+ if (!this.out.isTTY)
1014
+ return;
1015
+ if (process.env.AIDEN_NO_REFORMAT === '1')
1016
+ return;
1017
+ // Cheap heuristic: only re-render when there's structure that
1018
+ // benefits from formatting. Avoids flicker on short prose replies.
1019
+ const hasStructure = /^#{1,6}\s/m.test(buffered) ||
1020
+ /^\s*[-*+]\s/m.test(buffered) ||
1021
+ /^\s*\d+\.\s/m.test(buffered) ||
1022
+ /^>\s/m.test(buffered) ||
1023
+ /```/.test(buffered);
1024
+ if (!hasStructure)
1025
+ return;
1026
+ try {
1027
+ // Erase the raw streamed body in place. We wrote `lines + 1`
1028
+ // rows (header + body) — the header (`┃ Aiden`) stays, so we
1029
+ // walk back `lines` rows and clear each.
1030
+ // `\x1b[<n>F` = cursor-up-and-to-column-0 N times.
1031
+ // `\x1b[J` = erase from cursor to end of screen.
1032
+ if (lines > 0) {
1033
+ this.out.write(`\x1b[${lines}F\x1b[J`);
1034
+ }
1035
+ const formatted = this.markdown(buffered).trimEnd();
1036
+ const indented = formatted
1037
+ .split('\n')
1038
+ .map((ln) => (ln ? ` ${ln}` : ''))
1039
+ .join('\n');
1040
+ this.out.write(indented + '\n');
1041
+ }
1042
+ catch {
1043
+ // If anything goes wrong with the re-render, leave the raw
1044
+ // streamed text in place — graceful degradation beats flicker
1045
+ // + corrupted output.
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Phase v4.1-reply-formatting: render the optional "Sources"
1050
+ * footer when AIDEN_CITATIONS=1 and the trace has fetch-class
1051
+ * tool calls. Pure write; safe to call after a turn completes
1052
+ * regardless of streaming/non-streaming. No-op when the env gate
1053
+ * is off or no sources surface.
1054
+ */
1055
+ printCitationFooter(trace) {
1056
+ const footer = (0, citationFooter_1.renderCitationFooter)(trace);
1057
+ if (!footer)
1058
+ return;
1059
+ if (!this.out.isTTY)
1060
+ return;
1061
+ this.out.write(footer);
827
1062
  }
828
1063
  /**
829
1064
  * Inline tool indicator. Printed between deltas when a tool call
@@ -840,6 +1075,57 @@ class Display {
840
1075
  }
841
1076
  }
842
1077
  exports.Display = Display;
1078
+ /**
1079
+ * Render a voice-mode status line. Pure builder — caller writes the
1080
+ * result via Display.streamPartial / direct stdout. Includes a
1081
+ * RMS-driven block bar when state is `recording`. The bar uses 8
1082
+ * unicode block-fill levels (▏ to █) over a 0..1500 RMS range,
1083
+ * which covers the practical loud-speech ceiling without saturating
1084
+ * for any reasonable mic preamp.
1085
+ *
1086
+ * Tier-3.1 (v4.1-tier3.1): replaced 🎤 / 🔊 emoji with text-state
1087
+ * badges `[REC]` (recording, error/red) and `[PLAY]` (speaking,
1088
+ * success/green). Idle/listening/transcribing get the neutral
1089
+ * `[VOX]` badge in muted colour. Same 4-char inner width keeps
1090
+ * subsequent column alignment intact.
1091
+ *
1092
+ * Examples:
1093
+ * voiceIndicator('idle') → '[VOX] idle (Space to talk)'
1094
+ * voiceIndicator('listening') → '[VOX] listening...'
1095
+ * voiceIndicator('recording', 800) → '[REC] ▌▌▌▌▌▌ recording (Space to stop, Esc to cancel)'
1096
+ * voiceIndicator('transcribing') → '[VOX] transcribing...'
1097
+ * voiceIndicator('speaking') → '[PLAY] speaking...'
1098
+ */
1099
+ function voiceIndicator(state, rms = 0) {
1100
+ const skin = (0, skinEngine_1.getSkinEngine)();
1101
+ const recBadge = skin.applyColors('[REC]', 'error');
1102
+ const playBadge = skin.applyColors('[PLAY]', 'success');
1103
+ const voxBadge = skin.applyColors('[VOX]', 'muted');
1104
+ switch (state) {
1105
+ case 'idle':
1106
+ return `${voxBadge} idle (Space to talk)`;
1107
+ case 'listening':
1108
+ return `${voxBadge} listening...`;
1109
+ case 'recording': {
1110
+ const bar = renderRmsBar(rms);
1111
+ return `${recBadge} ${bar} recording (Space to stop, Esc to cancel)`;
1112
+ }
1113
+ case 'transcribing':
1114
+ return `${voxBadge} transcribing...`;
1115
+ case 'speaking':
1116
+ return `${playBadge} speaking...`;
1117
+ default:
1118
+ return `${voxBadge} ${state}`;
1119
+ }
1120
+ }
1121
+ const BAR_WIDTH = 12;
1122
+ const BAR_FULL_RMS = 1500;
1123
+ /** RMS-driven horizontal block bar. 0..BAR_FULL_RMS → 0..BAR_WIDTH chars. */
1124
+ function renderRmsBar(rms) {
1125
+ const safe = Math.max(0, Math.min(rms, BAR_FULL_RMS));
1126
+ const filled = Math.round((safe / BAR_FULL_RMS) * BAR_WIDTH);
1127
+ return '▌'.repeat(filled) + ' '.repeat(BAR_WIDTH - filled);
1128
+ }
843
1129
  // ── Phase 23.5 — tool row helpers ─────────────────────────────────────
844
1130
  /** Width the tool name is padded to so brackets line up across rows. */
845
1131
  const TOOL_ROW_NAME_PAD = 16;
@@ -34,6 +34,7 @@ exports.checkNpxAvailable = checkNpxAvailable;
34
34
  exports.checkSkillsDir = checkSkillsDir;
35
35
  exports.checkBundledManifest = checkBundledManifest;
36
36
  exports.checkPlatformPaths = checkPlatformPaths;
37
+ exports.checkAudioBackend = checkAudioBackend;
37
38
  exports.checkLicense = checkLicense;
38
39
  exports.checkUpdate = checkUpdate;
39
40
  exports.checkLogsWritable = checkLogsWritable;
@@ -48,6 +49,7 @@ const paths_1 = require("../../core/v4/paths");
48
49
  const license_1 = require("../../core/v4/license");
49
50
  const checkUpdate_1 = require("../../core/v4/update/checkUpdate");
50
51
  const box_1 = require("./box");
52
+ const audioBackend_1 = require("../../core/voice/audioBackend");
51
53
  const DEFAULT_TIMEOUT_MS = 3000;
52
54
  /** Wrap a promise with a timeout. The timed-out path resolves to the fallback result. */
53
55
  async function withTimeout(p, ms, fallback) {
@@ -421,6 +423,44 @@ async function checkPlatformPaths(paths) {
421
423
  };
422
424
  }
423
425
  }
426
+ /**
427
+ * Phase v4.1-cross-platform: probe per-OS audio backends so a user
428
+ * who installs Aiden fresh on Linux/macOS gets a clear "install sox"
429
+ * pointer instead of a stack trace the first time they run /voice.
430
+ *
431
+ * Reports as INFO not FAILURE — the agent loop works fine without
432
+ * voice support; we just want to surface the install hint.
433
+ */
434
+ async function checkAudioBackend() {
435
+ const t0 = Date.now();
436
+ const playback = (0, audioBackend_1.detectBackend)('playback');
437
+ const record = (0, audioBackend_1.detectBackend)('record');
438
+ const known = {
439
+ playback: (0, audioBackend_1.listKnownBackends)('playback').map((b) => b.label),
440
+ record: (0, audioBackend_1.listKnownBackends)('record').map((b) => b.label),
441
+ };
442
+ if (playback && record) {
443
+ return {
444
+ name: 'audio backend',
445
+ passed: true,
446
+ message: `${process.platform}: playback=${playback.label} · record=${record.label}`,
447
+ durationMs: Date.now() - t0,
448
+ };
449
+ }
450
+ // Pass=true — informational. The suggestion carries the fix.
451
+ const missing = [];
452
+ if (!playback)
453
+ missing.push((0, audioBackend_1.missingBackendMessage)('playback'));
454
+ if (!record)
455
+ missing.push((0, audioBackend_1.missingBackendMessage)('record'));
456
+ return {
457
+ name: 'audio backend',
458
+ passed: true,
459
+ message: `${process.platform}: voice features will not work — backends missing (known: ${[...new Set([...known.playback, ...known.record])].join(', ') || 'none'})`,
460
+ suggestion: missing.join(' || '),
461
+ durationMs: Date.now() - t0,
462
+ };
463
+ }
424
464
  /**
425
465
  * Phase 20 Task 7: license-server reachability + local cache state.
426
466
  * `/doctor` shouldn't block when offline — we treat both "no local cache
@@ -586,6 +626,7 @@ async function runDoctor(opts = {}) {
586
626
  results.push(await checkBundledManifest(paths));
587
627
  results.push(await checkPlatformPaths(paths));
588
628
  results.push(await checkLogsWritable(paths));
629
+ results.push(await checkAudioBackend());
589
630
  // Phase 20 Task 7: license + update health.
590
631
  results.push(await checkLicense({ paths, fetchImpl, timeoutMs }));
591
632
  results.push(await checkUpdate({ paths, installedVersion, timeoutMs }));
@@ -33,9 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.KNOWN_PROVIDER_KEYS = void 0;
36
37
  exports.loadAidenEnvFile = loadAidenEnvFile;
37
38
  exports.getEnvSource = getEnvSource;
38
39
  exports.__resetEnvSources = __resetEnvSources;
40
+ exports.resolveAidenInstallDir = resolveAidenInstallDir;
41
+ exports.loadMcpEnvSources = loadMcpEnvSources;
42
+ exports.describeProviderKeys = describeProviderKeys;
39
43
  /**
40
44
  * Copyright (c) 2026 Shiva Deore (Taracod).
41
45
  * Licensed under AGPL-3.0. See LICENSE for details.
@@ -59,6 +63,7 @@ exports.__resetEnvSources = __resetEnvSources;
59
63
  * - 'unset' — not in process.env
60
64
  */
61
65
  const fs = __importStar(require("node:fs"));
66
+ const path = __importStar(require("node:path"));
62
67
  const ENV_SOURCE_TAG = Symbol.for('aiden.envSource');
63
68
  function getMap() {
64
69
  let m = globalThis[ENV_SOURCE_TAG];
@@ -121,3 +126,103 @@ function getEnvSource(key) {
121
126
  function __resetEnvSources() {
122
127
  getMap().clear();
123
128
  }
129
+ // ── Phase v4.1-mcp.2 — Multi-source env loader for `aiden mcp serve` ──
130
+ //
131
+ // When Claude Desktop / Cursor / Claude Code spawn `aiden mcp serve`
132
+ // over stdio, they pass an EMPTY env block by default. Without an
133
+ // explicit `env: {...}` per-server entry in the client config, the
134
+ // spawned aiden has no GROQ_API_KEY, GEMINI_API_KEY, etc. Provider-
135
+ // using tools (subagent_fanout, web_search, fetch_url, …) then fail
136
+ // with "no providers configured".
137
+ //
138
+ // Fix: at MCP serve startup, eagerly load .env files from a small
139
+ // list of well-known locations into process.env. Same fill-only
140
+ // semantics as `loadAidenEnvFile` (preset > file). Caller passes
141
+ // `paths.envFile` (covers `~/.aiden/.env` and Windows-equivalent).
142
+ // We additionally probe the install directory so a project-local
143
+ // `.env` in the Aiden checkout works out of the box.
144
+ //
145
+ // NEVER log the values — only the source path and the set of keys
146
+ // (names) detected.
147
+ /** Walk up from `from` looking for an Aiden install root — a directory
148
+ * containing a `package.json` whose `name` is `aiden-runtime`. Returns
149
+ * null when no candidate is found within `maxDepth` levels. */
150
+ function resolveAidenInstallDir(from = __dirname, maxDepth = 8) {
151
+ let dir = path.resolve(from);
152
+ for (let i = 0; i < maxDepth; i += 1) {
153
+ const pkg = path.join(dir, 'package.json');
154
+ try {
155
+ const text = fs.readFileSync(pkg, 'utf8');
156
+ const parsed = JSON.parse(text);
157
+ if (parsed.name === 'aiden-runtime')
158
+ return dir;
159
+ }
160
+ catch { /* not present or unparseable, walk up */ }
161
+ const parent = path.dirname(dir);
162
+ if (parent === dir)
163
+ break;
164
+ dir = parent;
165
+ }
166
+ return null;
167
+ }
168
+ /** Load `.env` files for `aiden mcp serve`. Order:
169
+ *
170
+ * 1. `<aiden_install_dir>/.env` — project-local, dev convenience
171
+ * 2. `aidenHomeEnv` — per-user, `paths.envFile`
172
+ *
173
+ * 3. process.env — already loaded; takes precedence over both
174
+ * via fill-only semantics.
175
+ *
176
+ * Caller logs the report via the mcp-stdio logger (stderr-safe).
177
+ * Values are NEVER returned — only counts + key names. */
178
+ function loadMcpEnvSources(opts) {
179
+ const attempts = [];
180
+ const installDir = opts.installDir ?? resolveAidenInstallDir();
181
+ const candidates = [];
182
+ if (installDir)
183
+ candidates.push(path.join(installDir, '.env'));
184
+ candidates.push(opts.aidenHomeEnv);
185
+ let appliedTotal = 0;
186
+ for (const file of candidates) {
187
+ const before = new Set(Object.keys(process.env));
188
+ let exists = false;
189
+ try {
190
+ fs.accessSync(file, fs.constants.R_OK);
191
+ exists = true;
192
+ }
193
+ catch { /* missing — record and skip */ }
194
+ if (exists)
195
+ loadAidenEnvFile(file);
196
+ const appliedKeys = [];
197
+ for (const k of Object.keys(process.env)) {
198
+ if (!before.has(k))
199
+ appliedKeys.push(k);
200
+ }
201
+ appliedTotal += appliedKeys.length;
202
+ attempts.push({ path: file, exists, appliedKeys });
203
+ }
204
+ return { attempts, appliedTotal };
205
+ }
206
+ /** The provider-key surface aiden cares about for `mcp status` output.
207
+ * Listed explicitly so we never accidentally enumerate / log a
208
+ * newly-added secret. */
209
+ exports.KNOWN_PROVIDER_KEYS = [
210
+ 'GROQ_API_KEY',
211
+ 'GEMINI_API_KEY',
212
+ 'TOGETHER_API_KEY',
213
+ 'OPENROUTER_API_KEY',
214
+ 'ANTHROPIC_API_KEY',
215
+ 'OPENAI_API_KEY',
216
+ 'CEREBRAS_API_KEY',
217
+ 'NVIDIA_API_KEY',
218
+ 'COHERE_API_KEY',
219
+ ];
220
+ /** Snapshot of provider-key presence + source. Values NEVER returned;
221
+ * only the source tag (`preset`/`aiden-env`/`unset`). */
222
+ function describeProviderKeys(keys = exports.KNOWN_PROVIDER_KEYS) {
223
+ return keys.map((key) => ({
224
+ key,
225
+ present: typeof process.env[key] === 'string' && process.env[key].length > 0,
226
+ source: getEnvSource(key),
227
+ }));
228
+ }
@@ -0,0 +1,74 @@
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/ghostMatch.ts — Tier-3.1.1 (v4.1-tier3.1.1)
10
+ *
11
+ * Compute the best ghost-text match for what the user has typed so far.
12
+ * The aidenPrompt component calls `findGhost(typed, ctx)` on every
13
+ * keystroke and renders the returned suggestion (in dim) past the
14
+ * cursor.
15
+ *
16
+ * Two modes:
17
+ *
18
+ * 1. Slash mode (typed starts with `/`): match against registered
19
+ * slash command names + aliases. Longest start-with match wins
20
+ * (so `/p` favours `/plugins` over `/personality`/`/providers`
21
+ * only if no shorter unique match exists; ties broken by
22
+ * alphabetical order so the result is deterministic).
23
+ *
24
+ * 2. Free-text mode: match against recent user prompts (most recent
25
+ * first). Returns the first prompt that starts with `typed` and
26
+ * is strictly longer.
27
+ *
28
+ * Returns the SUFFIX to append after the typed text, or `null` if no
29
+ * match exists. Empty/whitespace-only typed text → null. Typed text
30
+ * containing a paste-compression label (`[paste #N: …]`) → null
31
+ * (don't suggest over a compressed paste).
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.findGhost = findGhost;
35
+ const PASTE_LABEL_RE = /\[paste #\d+:[^\]]*\]/;
36
+ /**
37
+ * Return the suffix to append (everything past `typed`) or null.
38
+ *
39
+ * Examples:
40
+ * findGhost('/cr', { slashNames: ['cron','clear'], … }) → 'on'
41
+ * findGhost('/x', { slashNames: ['cron'], … }) → null
42
+ * findGhost('how ', { history: ['how do I quit'], … }) → 'do I quit'
43
+ */
44
+ function findGhost(typed, ctx) {
45
+ if (!typed || typed.trim().length === 0)
46
+ return null;
47
+ if (PASTE_LABEL_RE.test(typed))
48
+ return null;
49
+ if (typed.startsWith('/')) {
50
+ const stem = typed.slice(1);
51
+ if (stem.length === 0)
52
+ return null;
53
+ const all = [...ctx.slashNames, ...ctx.slashAliases];
54
+ // Longest start-with match wins. Ties broken alphabetically.
55
+ const candidates = all
56
+ .filter((n) => n.startsWith(stem) && n.length > stem.length)
57
+ .sort();
58
+ if (candidates.length === 0)
59
+ return null;
60
+ // Prefer the SHORTEST candidate that uniquely starts with stem
61
+ // (more likely the user-intended completion). Fall back to the
62
+ // alphabetically-first if all share the same length.
63
+ const shortest = candidates.reduce((best, c) => c.length < best.length ? c : best);
64
+ return shortest.slice(stem.length);
65
+ }
66
+ // Free-text — history fallback. Prefer the most-recent strict
67
+ // start-with match.
68
+ for (const past of ctx.history) {
69
+ if (past.startsWith(typed) && past.length > typed.length) {
70
+ return past.slice(typed.length);
71
+ }
72
+ }
73
+ return null;
74
+ }