aiden-runtime 4.1.2 → 4.1.3

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.
@@ -1449,6 +1449,12 @@ async function buildAgentRuntime(cliOpts, opts) {
1449
1449
  mcpClient,
1450
1450
  providerId,
1451
1451
  modelId,
1452
+ // v4.1.3-prebump: forward the precedence-case label so the boot
1453
+ // card can render a "where this choice came from" annotation.
1454
+ // The case-3 (persisted-config) branch was confusing users who
1455
+ // expected auto-pick to kick in — surfacing the source closes the
1456
+ // information asymmetry.
1457
+ bootSource,
1452
1458
  resumeSessionId,
1453
1459
  fallbackAdapter,
1454
1460
  personalityManager,
@@ -1475,6 +1481,10 @@ async function runInteractiveChat(cliOpts, opts) {
1475
1481
  config: runtime.config,
1476
1482
  initialProviderId: runtime.providerId,
1477
1483
  initialModelId: runtime.modelId,
1484
+ // v4.1.3-prebump: pass through the precedence-case label so the
1485
+ // boot card can render a dim source annotation under the version
1486
+ // pill ("persisted from prior session" / "auto-picked" / …).
1487
+ initialBootSource: runtime.bootSource,
1478
1488
  resumeSessionId: runtime.resumeSessionId,
1479
1489
  yoloMode: !!cliOpts.yolo,
1480
1490
  fallbackAdapter: runtime.fallbackAdapter,
@@ -102,6 +102,21 @@ class CliCallbacks {
102
102
  }
103
103
  if (err) {
104
104
  handle.fail(ms);
105
+ // v4.1.3-essentials: when the tool's failure payload includes a
106
+ // structured capability card (auth missing, platform unsupported),
107
+ // render the card immediately after the fail row. The card sits
108
+ // on its own multi-line block — the fail row is still useful as
109
+ // the action timeline anchor; the card adds the state assessment
110
+ // the user actually needs. No card → plain failure surface.
111
+ if (result?.capabilityCard) {
112
+ this.display.capabilityCard(result.capabilityCard);
113
+ }
114
+ return;
115
+ }
116
+ // v4.1.3-repl-polish: degraded outcome — tool completed but with a
117
+ // partial / best-effort result. Show in trail yellow instead of silent.
118
+ if (result?.degraded) {
119
+ handle.degraded(ms, result.degradedReason);
105
120
  return;
106
121
  }
107
122
  handle.ok(ms);
@@ -59,6 +59,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
59
59
  exports.BOOT_TRY_HINT = exports.ChatSession = void 0;
60
60
  exports.parseSessionBulletsResponse = parseSessionBulletsResponse;
61
61
  exports.renderCommandLabel = renderCommandLabel;
62
+ exports.bootSourceLabel = bootSourceLabel;
62
63
  exports.detectOS = detectOS;
63
64
  exports.detectShell = detectShell;
64
65
  exports.formatStatusState = formatStatusState;
@@ -74,7 +75,12 @@ const sessionSummaryGate_1 = require("./sessionSummaryGate");
74
75
  const aidenPrompt_1 = __importDefault(require("./aidenPrompt"));
75
76
  const historyStore_1 = require("./historyStore");
76
77
  const modelMetadata_1 = require("../../core/v4/modelMetadata");
78
+ // v4.1.3-prebump: classify provider errors so the catch path can show
79
+ // a tailored action hint (e.g. groq 413 → "switch to chatgpt-plus")
80
+ // instead of the generic "/model or aiden doctor" line.
81
+ const errors_1 = require("../../providers/v4/errors");
77
82
  const sessionDistiller_1 = require("../../core/v4/sessionDistiller");
83
+ const sessionEndCard_1 = require("./display/sessionEndCard");
78
84
  const version_1 = require("../../core/version");
79
85
  const distillationStore_1 = require("../../core/v4/distillationStore");
80
86
  const promotionCandidates_1 = require("../../core/v4/promotionCandidates");
@@ -154,7 +160,15 @@ const STATUS_BAR_WIDTH = 10;
154
160
  * Above this we abandon the LLM half (still write a deterministic-
155
161
  * only distillation so the session isn't lost) and exit honestly.
156
162
  */
157
- const SUMMARY_TIMEOUT_MS_DEFAULT = 4000;
163
+ /**
164
+ * v4.1.3-essentials distillation-fix: bumped 4000 → 12000ms in
165
+ * lockstep with `sessionDistiller.DEFAULT_TIMEOUT_MS`. Same
166
+ * rationale — chatgpt-plus Codex cold-start latency for 800-token
167
+ * summaries regularly exceeds 4s, killing the distillation +
168
+ * promotion-prompt path. Env override `AIDEN_SUMMARY_TIMEOUT_MS`
169
+ * still respected.
170
+ */
171
+ const SUMMARY_TIMEOUT_MS_DEFAULT = 12000;
158
172
  function resolveSummaryTimeoutMs() {
159
173
  const raw = process.env.AIDEN_SUMMARY_TIMEOUT_MS;
160
174
  if (!raw)
@@ -162,6 +176,35 @@ function resolveSummaryTimeoutMs() {
162
176
  const parsed = Number.parseInt(raw, 10);
163
177
  return Number.isFinite(parsed) && parsed > 0 ? parsed : SUMMARY_TIMEOUT_MS_DEFAULT;
164
178
  }
179
+ /**
180
+ * v4.1.3-prebump: map a providerBootSelector precedence-case label to
181
+ * a human-readable hint rendered under the boot card's status pills.
182
+ *
183
+ * Returns `null` for the explicit-selection cases (`cli-flag`, with-or-
184
+ * without -partial) where the source isn't surprising. Annotates the
185
+ * persisted-config / auto-priority / hardcoded-fallback paths so users
186
+ * understand "why this provider, why now".
187
+ *
188
+ * Pure helper — exported for unit testing.
189
+ */
190
+ function bootSourceLabel(source) {
191
+ switch (source) {
192
+ case 'persisted-config':
193
+ return '(persisted from prior session — /model to change)';
194
+ case 'config-partial':
195
+ return '(partial config + auto-resolved companion)';
196
+ case 'auto-priority':
197
+ return '(auto-picked — first authed provider)';
198
+ case 'hardcoded-fallback':
199
+ return '(no authed providers — using legacy default)';
200
+ case 'cli-flag':
201
+ case 'cli-flag-partial':
202
+ // Explicit CLI override — user knows why; no annotation.
203
+ return null;
204
+ default:
205
+ return null;
206
+ }
207
+ }
165
208
  class ChatSession {
166
209
  constructor(opts) {
167
210
  this.opts = opts;
@@ -208,6 +251,13 @@ class ChatSession {
208
251
  * populated alongside it after a verified write.
209
252
  */
210
253
  this.lastDistillation = null;
254
+ /**
255
+ * Absolute path the most recent distillation JSON was written to.
256
+ * Captured at write-time and surfaced in the session-end card so the
257
+ * user has a concrete artifact to inspect or feed to recall_session.
258
+ * Null when the write failed or no distillation has been produced.
259
+ */
260
+ this.lastDistillationPath = null;
211
261
  this.currentProviderId = opts.initialProviderId;
212
262
  this.currentModelId = opts.initialModelId;
213
263
  this.modelMetadata = opts.modelMetadata ?? new modelMetadata_1.ModelMetadata();
@@ -298,6 +348,14 @@ class ChatSession {
298
348
  catch (err) {
299
349
  this.opts.display.warn(`Session summary skipped on ${sig}: ${err.message}`);
300
350
  }
351
+ // v4.1.3-repl-polish: render session-end card before farewell when
352
+ // a distillation was written this session. Pass the on-disk path
353
+ // so the card surfaces the artifact location to the user.
354
+ if (this.lastDistillation) {
355
+ for (const line of (0, sessionEndCard_1.renderSessionEndCard)(this.lastDistillation, (t, k) => this.opts.display.applyColors(t, k), this.lastDistillationPath)) {
356
+ this.opts.display.write(line + '\n');
357
+ }
358
+ }
301
359
  this.opts.display.dim('Goodbye.');
302
360
  process.exit(0);
303
361
  };
@@ -430,6 +488,12 @@ class ChatSession {
430
488
  // is resumed in the same process (not today's behavior),
431
489
  // otherwise they're skipped — documented in commit.
432
490
  await this.maybeRunPromotion(promptApi);
491
+ // v4.1.3-repl-polish: session-end card before farewell.
492
+ if (this.lastDistillation) {
493
+ for (const line of (0, sessionEndCard_1.renderSessionEndCard)(this.lastDistillation, (t, k) => this.opts.display.applyColors(t, k), this.lastDistillationPath)) {
494
+ this.opts.display.write(line + '\n');
495
+ }
496
+ }
433
497
  break;
434
498
  }
435
499
  if (result.clearHistory)
@@ -555,6 +619,15 @@ class ChatSession {
555
619
  toolTrace: this.sessionToolTrace,
556
620
  auxiliaryClient: this.opts.auxiliaryClient,
557
621
  timeoutMs,
622
+ // v4.1.3-essentials distillation-fix: route the new
623
+ // diagnostic signal to a dim line so the user can see WHICH
624
+ // of the three failure classes fired (timeout / call-fail /
625
+ // unparseable JSON). Before this hook, all three converged
626
+ // on a silent `partial:true` and the downstream "no bullets"
627
+ // warning didn't distinguish them.
628
+ onDiagnostic: (msg) => {
629
+ this.opts.display.dim(`[distill] ${msg}`);
630
+ },
558
631
  });
559
632
  }
560
633
  catch (err) {
@@ -569,6 +642,7 @@ class ChatSession {
569
642
  const dir = node_path_1.default.join(this.opts.paths.root, 'distillations');
570
643
  try {
571
644
  const file = await (0, distillationStore_1.writeDistillation)(dir, dist);
645
+ this.lastDistillationPath = file;
572
646
  this.opts.display.dim(`Session distillation${dist.partial ? ' (partial)' : ''} saved to ${file}`);
573
647
  }
574
648
  catch (err) {
@@ -896,7 +970,42 @@ class ChatSession {
896
970
  if (streamingActive)
897
971
  this.opts.display.streamComplete();
898
972
  const msg = err?.message ?? String(err);
899
- this.opts.display.printError(msg, 'Run `/model` to switch providers or `aiden doctor` to diagnose.');
973
+ // v4.1.3-prebump: classify the error so the suggestion below
974
+ // points at the actual fix instead of the generic "/model or
975
+ // doctor" line. 413 / 429 / auth get tailored hints; everything
976
+ // else keeps the legacy fallback. Use the live providerId so
977
+ // the user sees WHICH provider blew up (matters when fallback
978
+ // adapters rotate slots mid-turn).
979
+ const cls = (0, errors_1.classifyProviderError)(err);
980
+ const tailored = (0, errors_1.suggestForErrorClass)(cls, this.currentProviderId);
981
+ // v4.1.3-essentials: on `auth` class errors we have enough state
982
+ // (which provider, what to run) to render a capability card —
983
+ // structured "what auth's missing, what you can still do, how to
984
+ // fix" is more useful than the bare message + one-line hint.
985
+ // Other classes keep the printError single-line surface; their
986
+ // hints are already specific.
987
+ if (cls === 'auth') {
988
+ const p = this.currentProviderId;
989
+ this.opts.display.printError(msg);
990
+ this.opts.display.capabilityCard({
991
+ title: `${p} authentication required`,
992
+ canStill: [
993
+ 'Continue chatting if a non-auth provider is configured (run `/model`)',
994
+ 'Run `/auth status` to see which providers are signed in',
995
+ 'Run `aiden doctor --providers` for a fuller liveness probe',
996
+ ],
997
+ cannotReliably: [
998
+ `Call ${p} until credentials are refreshed`,
999
+ 'Trust any cached responses that depended on this provider',
1000
+ ],
1001
+ fix: `Run \`/auth login ${p}\` if it's an OAuth provider, or set the ` +
1002
+ `relevant API key env var. Then retry — no need to restart Aiden.`,
1003
+ });
1004
+ }
1005
+ else {
1006
+ this.opts.display.printError(msg, tailored
1007
+ ?? 'Run `/model` to switch providers or `aiden doctor` to diagnose.');
1008
+ }
900
1009
  this.setStatusState({ kind: 'ready' });
901
1010
  this.lastTurnElapsedMs = Date.now() - turnStartedAt;
902
1011
  }
@@ -979,6 +1088,15 @@ class ChatSession {
979
1088
  providerOk: !this.opts.unconfigured,
980
1089
  version: version_1.VERSION,
981
1090
  }) + '\n');
1091
+ // v4.1.3-prebump: dim source annotation under the pills row so the
1092
+ // user can see WHY this provider/model was chosen — closes the
1093
+ // information gap that made Case 3 (persisted-config) look like a
1094
+ // bug ("why is it still on groq when I auth'd chatgpt-plus?"). One
1095
+ // line, dim, only when the source is informative.
1096
+ const sourceLabel = bootSourceLabel(this.opts.initialBootSource);
1097
+ if (sourceLabel) {
1098
+ display.write(` ${display.muted(sourceLabel)}\n`);
1099
+ }
982
1100
  // Tier-3.1b: rule + environment/capabilities block + rule + scroll
983
1101
  // + bottom prompt hint. Skipped at <70 cols to keep the narrow
984
1102
  // boot card from wrapping into noise.
@@ -32,35 +32,31 @@ exports.doctor = {
32
32
  }
33
33
  ctx.display.info('Running diagnostic checks...');
34
34
  const report = await (0, doctor_1.runDoctor)({ paths: ctx.paths });
35
- // Phase 22 Task 5A: orange-bordered rounded box; rows + summary
36
- // assembled by renderHealthBox so the slash command stays a thin
37
- // adapter and the same renderer can be reused by `aiden doctor`
38
- // CLI in a future polish pass.
39
- ctx.display.write((0, doctor_1.renderHealthBox)(report, ctx.display) + '\n');
40
- // Phase 23.1: surface session-scoped skill-enforcement counters.
41
- // Lives only on the live agent (process-scoped, no persistence) so
42
- // `aiden doctor` CLI subcommand correctly omits this — the
43
- // counters would always be zero there.
35
+ // v4.1.3-essentials doctor-polish: pull in-process subsystem
36
+ // health + skill-outcome data into the same report so they
37
+ // render as additional grouped sections inside the health box,
38
+ // not as disconnected blocks below it. `subsystemHealthResults`
39
+ // / `skillOutcomeResults` return empty arrays when their
40
+ // sources are unavailable so the grouped-renderer simply drops
41
+ // those sections.
44
42
  if (ctx.agent) {
45
- const m = ctx.agent.getSkillEnforcementMetrics();
46
- ctx.display.write(
47
- // Phase 23.4b: surface the Stage-0 intent pre-arm counter so
48
- // smoke runs can confirm the regex fired on bug-Y queries.
49
- `[skill-enforcement] armed=${m.armed} pre-armed=${m.preArmed} recovered=${m.recovered} failed=${m.failed} (session)\n`);
50
- // Phase 23.4a: same shape, different concern URL provenance
51
- // gate counters. blocked = open_url calls rejected for unknown
52
- // YouTube ids; recovered = corrective retry produced a real
53
- // youtube_search; failed = retry cap exceeded and the turn
54
- // ended with an honest-failure message.
55
- const u = ctx.agent.getUrlProvenanceMetrics();
56
- ctx.display.write(`[url-provenance] blocked=${u.blocked} recovered=${u.recovered} failed=${u.failed} (session)\n`);
57
- // Phase 23.4a-fix2: empty-response counters. detected =
58
- // Codex backend completed a turn with no content and no tool
59
- // calls; retried = corrective system message injected (cap
60
- // 1/turn); recovered = retry yielded a non-empty reply.
61
- const e = ctx.agent.getEmptyResponseMetrics();
62
- ctx.display.write(`[empty-response] detected=${e.detected} retried=${e.retried} recovered=${e.recovered} (session)\n`);
43
+ const a = ctx.agent;
44
+ report.results.push(...(0, doctor_1.subsystemHealthResults)(a.subsystemHealthRegistry));
45
+ report.results.push(...(0, doctor_1.skillOutcomeResults)(a.skillOutcomeTracker));
46
+ // v4.1.3-essentials doctor-polish: session-scoped counters
47
+ // (skill enforcement / URL provenance / empty response) now
48
+ // fold into the same report so they render as a "Session
49
+ // counters" group INSIDE the box instead of as orphan
50
+ // `display.write` lines below it. Previous code emitted them
51
+ // as 3 separate `[bracket-prefix] key=N ...` lines after
52
+ // renderHealthBox closed visually disconnected.
53
+ report.results.push(...(0, doctor_1.sessionCounterResults)(ctx.agent));
63
54
  }
55
+ // v4.1.3-essentials doctor-polish: renderHealthBox now groups
56
+ // results by section header with a top summary. Same renderer
57
+ // is used by `aiden doctor` CLI path so both surfaces stay in
58
+ // visual sync (Path-A unification).
59
+ ctx.display.write((0, doctor_1.renderHealthBox)(report, ctx.display) + '\n');
64
60
  return {};
65
61
  },
66
62
  };
@@ -106,7 +106,36 @@ exports.model = {
106
106
  return {};
107
107
  }
108
108
  }
109
- ctx.display.success(`Now using ${providerId}:${modelId}`);
109
+ // v4.1.3-prebump: persist the selection to config.yaml so the NEXT
110
+ // boot honours the user's choice. Without this, `/model` only
111
+ // updated the live session — and the persisted `model.provider /
112
+ // model.modelId` keys (which Case 3 in providerBootSelector
113
+ // consults first) silently kept their stale values from the
114
+ // previous wizard run. Result: every reboot snapped the user back
115
+ // to the wizard's original pick (typically groq + llama-3.3-70b),
116
+ // confusing /model into looking like a "session-only" switch.
117
+ //
118
+ // The `aiden model` CLI subcommand (aidenCLI.ts:1773-1777) has
119
+ // always persisted; this brings the REPL `/model` path in line.
120
+ // Best-effort: if `ctx.config` isn't plumbed (test harness, etc.)
121
+ // we still succeeded for the live session — emit a subtle warning
122
+ // instead of failing the whole switch.
123
+ let persisted = false;
124
+ if (ctx.config) {
125
+ try {
126
+ ctx.config.set('model.provider', providerId);
127
+ ctx.config.set('model.modelId', modelId);
128
+ await ctx.config.save();
129
+ persisted = true;
130
+ }
131
+ catch (err) {
132
+ ctx.display.warn(`Switched the live session but could not persist to config.yaml: ` +
133
+ `${err.message}. Next boot may revert.`);
134
+ }
135
+ }
136
+ ctx.display.success(persisted
137
+ ? `Now using ${providerId}:${modelId} (saved to config.yaml)`
138
+ : `Now using ${providerId}:${modelId} (session only — not persisted)`);
110
139
  return {};
111
140
  },
112
141
  };
@@ -0,0 +1,135 @@
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/display/capabilityCard.ts — Aiden v4.1.3-essentials.
10
+ *
11
+ * Renders the structured "capability card" tools return on certain
12
+ * failure classes:
13
+ * 1. Platform unsupported (e.g. media_transport called on Linux)
14
+ * 2. Auth missing (provider 401/403, or a tool that needs a specific
15
+ * OAuth/API key)
16
+ *
17
+ * Distinct from the one-line tool-trail row (`display.toolRow`) because
18
+ * it's a different category of information — a state assessment of
19
+ * what the user CAN still do versus what they CANNOT, with a one-line
20
+ * fix hint. Rendered as a box-bordered multi-line block.
21
+ *
22
+ * Pure module — takes the data + a colorize callback, returns lines.
23
+ * No I/O, no SkinEngine reach-through. Caller writes the result.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.renderCapabilityCard = renderCapabilityCard;
27
+ exports.truncToContent = truncToContent;
28
+ const box_1 = require("../box");
29
+ /** Total card width (chars). Wide enough for typical action labels;
30
+ * short enough to stay readable on narrow terminals. */
31
+ const CARD_WIDTH = 64;
32
+ /** Box-content width = CARD_WIDTH minus 2 border chars and 2 padding. */
33
+ const CONTENT_WIDTH = CARD_WIDTH - 4;
34
+ /**
35
+ * Render a capability card from `data`. Returns an array of lines
36
+ * (no trailing newlines). Caller writes them with appended `\n`:
37
+ *
38
+ * for (const line of renderCapabilityCard(data, colorize)) {
39
+ * display.write(line + '\n');
40
+ * }
41
+ *
42
+ * Layout:
43
+ *
44
+ * ┌── ⚠ <title> ──────────────────────────────┐
45
+ * │ │
46
+ * │ Can still: │
47
+ * │ ✓ <action 1> │
48
+ * │ ✓ <action 2> │
49
+ * │ │
50
+ * │ Cannot reliably: │
51
+ * │ ✗ <action 1> │
52
+ * │ ✗ <action 2> │
53
+ * │ │
54
+ * │ Fix: <one-line guidance> │
55
+ * └───────────────────────────────────────────┘
56
+ *
57
+ * Empty `canStill` or `cannotReliably` arrays cause the corresponding
58
+ * section to be omitted (no empty heading). Always renders at least
59
+ * the title + fix line so the user has actionable signal.
60
+ */
61
+ function renderCapabilityCard(data, colorize) {
62
+ // Pre-color the section headings + bullet markers.
63
+ const heading = (s) => colorize(s, 'warn');
64
+ const okMark = colorize('✓', 'success');
65
+ const noMark = colorize('✗', 'error');
66
+ const fixLbl = colorize('Fix:', 'tool');
67
+ // Compose the inner rows that boxSharp will wrap. Each row is the
68
+ // CONTENT (no border) — boxSharp adds the side borders + padding.
69
+ const rows = [];
70
+ if (data.canStill.length > 0) {
71
+ rows.push('');
72
+ rows.push(heading('Can still:'));
73
+ for (const action of data.canStill) {
74
+ rows.push(` ${okMark} ${truncToContent(action)}`);
75
+ }
76
+ }
77
+ if (data.cannotReliably.length > 0) {
78
+ rows.push('');
79
+ rows.push(heading('Cannot reliably:'));
80
+ for (const action of data.cannotReliably) {
81
+ rows.push(` ${noMark} ${truncToContent(action)}`);
82
+ }
83
+ }
84
+ rows.push('');
85
+ // Fix line — split-wrap to two lines if needed so long guidance
86
+ // doesn't get cut off mid-sentence by boxSharp's clipper.
87
+ const fixText = data.fix;
88
+ const fixPrefix = 'Fix: ';
89
+ const fixPrefixVis = (0, box_1.visibleLength)(fixPrefix);
90
+ if ((0, box_1.visibleLength)(fixText) + fixPrefixVis <= CONTENT_WIDTH) {
91
+ rows.push(`${fixLbl} ${fixText}`);
92
+ }
93
+ else {
94
+ rows.push(fixLbl);
95
+ // Wrap the fix text across content-width lines.
96
+ let remaining = fixText;
97
+ const wrapLimit = CONTENT_WIDTH - 2; // 2-space indent
98
+ while (remaining.length > 0) {
99
+ const chunk = remaining.length <= wrapLimit
100
+ ? remaining
101
+ : breakAtWord(remaining, wrapLimit);
102
+ rows.push(` ${chunk}`);
103
+ remaining = remaining.slice(chunk.length).trimStart();
104
+ }
105
+ }
106
+ rows.push('');
107
+ // Title is rendered into the top border by boxSharp. Prefix with
108
+ // a warning glyph (yellow) so the user reads it as an attention card.
109
+ const title = `${colorize('⚠', 'warn')} ${data.title}`;
110
+ return (0, box_1.boxSharp)(rows, CARD_WIDTH, title).split('\n');
111
+ }
112
+ /**
113
+ * Shorten an action label so it fits the bullet column with room for
114
+ * the marker (" ✓ ") and the border padding. Appends an ellipsis when
115
+ * truncated. Pure — exported for unit tests.
116
+ */
117
+ function truncToContent(s) {
118
+ // Reserve 6 chars for " ✓ " prefix + 2 for border padding margin.
119
+ const cap = CONTENT_WIDTH - 6;
120
+ if ((0, box_1.visibleLength)(s) <= cap)
121
+ return s;
122
+ return s.slice(0, cap - 1) + '…';
123
+ }
124
+ /**
125
+ * Break `s` at the last word boundary at-or-before `limit`. Falls back
126
+ * to a hard cut at `limit` when no whitespace appears in the prefix.
127
+ * Pure helper used by the Fix-line wrapper.
128
+ */
129
+ function breakAtWord(s, limit) {
130
+ if (s.length <= limit)
131
+ return s;
132
+ const slice = s.slice(0, limit);
133
+ const lastSpace = slice.lastIndexOf(' ');
134
+ return lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
135
+ }
@@ -0,0 +1,127 @@
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/display/sessionEndCard.ts — Aiden v4.1.3-repl-polish
10
+ *
11
+ * Renders a compact session-end summary card from a SessionDistillation.
12
+ *
13
+ * Returned as an array of plain lines (WITHOUT trailing '\n'). The caller
14
+ * writes them with a newline appended, e.g.:
15
+ *
16
+ * for (const line of renderSessionEndCard(dist, colorize)) {
17
+ * display.write(line + '\n');
18
+ * }
19
+ *
20
+ * Design rules (from spec):
21
+ * - Skip entirely when user_turns === 0 (silent/internal sessions).
22
+ * - Label column is colon-aligned to column LABEL_COL (14).
23
+ * - Session ID rendered in 'session' color (soft cyan).
24
+ * - Bullets / decisions / open_items shown only when non-empty.
25
+ * - Takes a `colorize` callback instead of a SkinEngine directly, so
26
+ * the function is fully unit-testable without a Display stack.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.renderSessionEndCard = renderSessionEndCard;
30
+ /** Width of the "Label:" prefix, colon included, padded to this column. */
31
+ const LABEL_COL = 14;
32
+ /** Horizontal rule width (chars). */
33
+ const HR_WIDTH = 48;
34
+ // ── Internal helpers ──────────────────────────────────────────────────────────
35
+ function labelRow(label, value) {
36
+ return `${`${label}:`.padEnd(LABEL_COL)}${value}`;
37
+ }
38
+ /**
39
+ * Format a wall-clock duration from two ISO timestamps.
40
+ * Returns '—' when the delta is ≤0 or non-finite (e.g. partial distillation).
41
+ */
42
+ function fmtDuration(startIso, endIso) {
43
+ const ms = new Date(endIso).getTime() - new Date(startIso).getTime();
44
+ if (!Number.isFinite(ms) || ms <= 0)
45
+ return '—';
46
+ const sec = ms / 1000;
47
+ if (sec < 60)
48
+ return `${Math.round(sec)}s`;
49
+ const mins = Math.floor(sec / 60);
50
+ const remSec = Math.round(sec - mins * 60);
51
+ return remSec > 0 ? `${mins}m ${remSec}s` : `${mins}m`;
52
+ }
53
+ // ── Public API ────────────────────────────────────────────────────────────────
54
+ /**
55
+ * Render a session-end card from `dist`.
56
+ *
57
+ * @param dist Completed SessionDistillation (may be partial).
58
+ * @param colorize Skin-aware colorizer — `(text, kind) => coloredText`.
59
+ * @param distillationPath Absolute path the distillation JSON was written to,
60
+ * if any. Rendered as a `Distillation:` row so the
61
+ * user has something concrete to inspect / pass to
62
+ * recall_session. Omitted from the card when null /
63
+ * undefined (e.g. write failed earlier).
64
+ * @returns Array of lines (no trailing newlines). Empty when
65
+ * `user_turns === 0`.
66
+ */
67
+ function renderSessionEndCard(dist, colorize, distillationPath) {
68
+ if (dist.user_turns === 0)
69
+ return [];
70
+ const lines = [];
71
+ const hr = colorize('─'.repeat(HR_WIDTH), 'muted');
72
+ const bullet = colorize('•', 'muted');
73
+ // ── Header block ───────────────────────────────────────────────────────
74
+ lines.push(hr);
75
+ lines.push(labelRow('Session', colorize(dist.session_id, 'session')));
76
+ lines.push(labelRow('Duration', fmtDuration(dist.started_at, dist.ended_at)));
77
+ lines.push(labelRow('Turns', String(dist.user_turns)));
78
+ lines.push(labelRow('Exit', dist.exit_path));
79
+ if (dist.files_touched.length > 0) {
80
+ // Show at most 6 files; truncate list with '…' if longer.
81
+ const shown = dist.files_touched.slice(0, 6);
82
+ const suffix = dist.files_touched.length > 6
83
+ ? ` … +${dist.files_touched.length - 6} more`
84
+ : '';
85
+ lines.push(labelRow('Files', shown.join(', ') + suffix));
86
+ }
87
+ else {
88
+ lines.push(labelRow('Files', colorize('(none)', 'muted')));
89
+ }
90
+ if (dist.tools_used.length > 0) {
91
+ const top = [...dist.tools_used]
92
+ .sort((a, b) => b.count - a.count)
93
+ .slice(0, 5)
94
+ .map(t => `${t.name}(${t.count})`)
95
+ .join(', ');
96
+ lines.push(labelRow('Tools', top));
97
+ }
98
+ if (distillationPath) {
99
+ lines.push(labelRow('Distillation', colorize(distillationPath, 'muted')));
100
+ }
101
+ lines.push(hr);
102
+ // ── Semantic sections (LLM-generated, may be empty on partial) ─────────
103
+ if (dist.bullets.length > 0) {
104
+ lines.push('');
105
+ lines.push(colorize('What happened:', 'heading'));
106
+ for (const b of dist.bullets) {
107
+ lines.push(` ${bullet} ${b}`);
108
+ }
109
+ }
110
+ if (dist.decisions.length > 0) {
111
+ lines.push('');
112
+ lines.push(colorize('Decisions:', 'heading'));
113
+ for (const d of dist.decisions) {
114
+ lines.push(` ${bullet} ${d}`);
115
+ }
116
+ }
117
+ if (dist.open_items.length > 0) {
118
+ lines.push('');
119
+ lines.push(colorize('Open items:', 'heading'));
120
+ for (const o of dist.open_items) {
121
+ lines.push(` ${bullet} ${o}`);
122
+ }
123
+ }
124
+ // Blank line so "Goodbye." has breathing room.
125
+ lines.push('');
126
+ return lines;
127
+ }