aiden-runtime 4.1.3 → 4.1.5

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.
@@ -27,10 +27,16 @@
27
27
  */
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.getReplyRenderer = getReplyRenderer;
30
+ exports.normalizeBlankLines = normalizeBlankLines;
30
31
  exports._resetForTests = _resetForTests;
31
32
  const marked_1 = require("marked");
32
33
  const skinEngine_1 = require("./skinEngine");
33
34
  const syntaxHighlight_1 = require("./syntaxHighlight");
35
+ // v4.1.4 reply-quality polish: single source of truth for frame math.
36
+ // Replaces 3 inline `Math.min(process.stdout.columns ?? 80, 100) - 4`
37
+ // callsites in this file with `getBodyWidth()` and adds soft-wrap for
38
+ // code-block lines that previously overflowed the viewport.
39
+ const frame_1 = require("./display/frame");
34
40
  // eslint-disable-next-line @typescript-eslint/no-var-requires
35
41
  const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
36
42
  function paint(kind) {
@@ -120,7 +126,11 @@ const CODE_BG_ON = '\x1b[48;2;50;50;60m';
120
126
  const CODE_BG_OFF = '\x1b[49m';
121
127
  function renderCodeBlock(code, lang) {
122
128
  const sk = (0, skinEngine_1.getSkinEngine)();
123
- const width = Math.min(process.stdout.columns ?? 80, 100) - 4;
129
+ // v4.1.4 reply-quality polish: width sourced from frame.ts. Same
130
+ // visual budget as the v4.1.3 formula (cols capped at 100, minus
131
+ // gutter+2) — but expressed via the shared helper so it tracks any
132
+ // future width-policy change in one place.
133
+ const width = (0, frame_1.getBodyWidth)();
124
134
  const langLabel = (lang ?? '').trim();
125
135
  // v4.1.3-essentials reply-polish: language tag on the top rule
126
136
  // already shipped; keep it. Bottom rule unlabeled (closing fence).
@@ -131,26 +141,43 @@ function renderCodeBlock(code, lang) {
131
141
  const body = (0, syntaxHighlight_1.isSupportedLang)(langLabel)
132
142
  ? (0, syntaxHighlight_1.highlightCode)(code, langLabel)
133
143
  : code;
134
- // v4.1.3-essentials reply-polish: each body line gets:
135
- // - 2-space outer indent (existing reply container indent)
144
+ // v4.1.4 reply-quality polish: per-line soft wrap. The rail + bg
145
+ // chrome adds 4 visible columns (` `, padding spaces around the
146
+ // line). Subtract those so wrap math targets the actual content
147
+ // budget. `hard: true` ensures even pathological long tokens
148
+ // (minified JS, hashes) break instead of escaping the frame.
149
+ //
150
+ // Width inside the body of a code line:
151
+ // gutter (3) + `│ ` (2) + leading-space (1) + CONTENT + trailing-space (1)
152
+ // → content budget = width - gutter - 4. We further cap at width to
153
+ // keep the fence rule aligned with the body's right margin.
154
+ const contentBudget = Math.max(8, width - frame_1.GUTTER - 4);
155
+ // v4.1.3-essentials reply-polish (preserved): each body line gets:
156
+ // - frame gutter (was 2-space outer indent; now uses shared GUTTER)
136
157
  // - left rail `│ ` painted muted (mirrors blockquote's `┃ ` rail
137
158
  // with a different glyph so they're visually distinct)
138
159
  // - 24-bit dark background wrapping the rail + content (subtle
139
160
  // "this is code" affordance without going full TUI box-frame)
140
- //
141
- // Strip the optional ANSI-only NO_COLOR gate by emitting bg codes
142
- // unconditionally — the skin engine already short-circuits inner
143
- // paint calls when NO_COLOR is set, and bare bg codes degrade
144
- // gracefully on terminals that don't render them.
145
161
  const rail = sk.applyColors('│', 'muted');
146
- const indented = body
147
- .split('\n')
148
- .map((ln) => ` ${rail} ${CODE_BG_ON} ${ln} ${CODE_BG_OFF}`)
149
- .join('\n');
162
+ const gutter = (0, frame_1.getIndent)(0);
163
+ // Wrap each source line independently — code-block semantics demand
164
+ // that a "logical line" remains visible as one continued unit even
165
+ // when soft-wrapped. The CODE_BG painting closes per VISUAL line so
166
+ // a wrap break doesn't bleed bg across the rail of the next row.
167
+ const wrappedLines = [];
168
+ for (const srcLine of body.split('\n')) {
169
+ const wrapped = (0, frame_1.wrap)(srcLine, contentBudget, { trim: false, hard: true });
170
+ for (const visualLine of wrapped.split('\n')) {
171
+ wrappedLines.push(`${gutter}${rail} ${CODE_BG_ON} ${visualLine} ${CODE_BG_OFF}`);
172
+ }
173
+ }
174
+ const indented = wrappedLines.join('\n');
175
+ // Top + bottom fence rules sit at the gutter too — visually anchors
176
+ // the block as a unit inside the assistant frame.
150
177
  return [
151
- sk.applyColors(top, 'muted'),
178
+ `${gutter}${sk.applyColors(top, 'muted')}`,
152
179
  indented,
153
- sk.applyColors(bot, 'muted'),
180
+ `${gutter}${sk.applyColors(bot, 'muted')}`,
154
181
  '',
155
182
  ].join('\n') + '\n';
156
183
  }
@@ -222,6 +249,97 @@ function renderHeading(text, depth) {
222
249
  function renderListItem(text) {
223
250
  return text;
224
251
  }
252
+ /**
253
+ * v4.1.4 reply-quality polish — Fix C helper.
254
+ *
255
+ * Render a single list item's tokens correctly, expanding inline
256
+ * emphasis (strong, em, codespan) that the prior `parser.parse` path
257
+ * silently stranded as raw text.
258
+ *
259
+ * Marked v15 token shapes for list items:
260
+ *
261
+ * Tight list (default — no blank lines between items):
262
+ * list_item.tokens = [
263
+ * { type: 'text', text: '**bold**',
264
+ * tokens: [ { type: 'strong', ... } ] ← inline children
265
+ * }
266
+ * ]
267
+ *
268
+ * Loose list (blank line between items, OR an item with multiple
269
+ * paragraphs):
270
+ * list_item.tokens = [
271
+ * { type: 'paragraph', tokens: [ inline children… ] },
272
+ * { type: 'paragraph', tokens: [ … ] },
273
+ * ]
274
+ *
275
+ * Nested list (a list-token inside an item):
276
+ * list_item.tokens = [
277
+ * { type: 'text', tokens: [...] }, ← the item's own text first
278
+ * { type: 'list', items: [...] }, ← then the nested list
279
+ * ]
280
+ *
281
+ * Item with fenced code block:
282
+ * list_item.tokens = [
283
+ * { type: 'text', ... },
284
+ * { type: 'code', text: '…', lang: '…' },
285
+ * ]
286
+ *
287
+ * Dispatch rules:
288
+ * - `text` with nested `.tokens` → parseInline(tokens)
289
+ * - `text` with only `.text` → fall through to raw text
290
+ * - `paragraph` → parseInline(paragraph.tokens) + '\n'
291
+ * - `list` / `code` / other block → parser.parse([token]) (block path)
292
+ *
293
+ * Returns the joined rendered string. Pure-ish: depends on marked's
294
+ * parser instance (closure-captured) but never mutates it.
295
+ */
296
+ function renderListItemTokens(it, parser) {
297
+ const toks = Array.isArray(it.tokens) ? it.tokens : [];
298
+ if (toks.length === 0)
299
+ return it.text ?? '';
300
+ const out = [];
301
+ for (const raw of toks) {
302
+ if (typeof raw !== 'object' || raw === null)
303
+ continue;
304
+ const tk = raw;
305
+ const type = tk.type;
306
+ // Inline-only wrapper (tight-list common case). The `text` outer
307
+ // token holds inline children we want to expand into ANSI.
308
+ if (type === 'text') {
309
+ if (Array.isArray(tk.tokens) && tk.tokens.length > 0 && parser.parseInline) {
310
+ out.push(parser.parseInline(tk.tokens));
311
+ }
312
+ else {
313
+ out.push(tk.text ?? '');
314
+ }
315
+ continue;
316
+ }
317
+ // Paragraph block (loose-list case). Marked wraps each paragraph's
318
+ // inline content in `.tokens`; render those inline + append a
319
+ // newline so multi-paragraph items stack visually.
320
+ if (type === 'paragraph') {
321
+ if (Array.isArray(tk.tokens) && tk.tokens.length > 0 && parser.parseInline) {
322
+ out.push(parser.parseInline(tk.tokens));
323
+ out.push('\n');
324
+ }
325
+ else {
326
+ out.push(tk.text ?? '');
327
+ }
328
+ continue;
329
+ }
330
+ // Nested list, fenced code, or any other block-level token. The
331
+ // block parser handles these via the normal dispatch (which calls
332
+ // back into our own `renderer.list` override for nested lists —
333
+ // depth counter is already incremented before we got here).
334
+ if (parser.parse) {
335
+ out.push(parser.parse([tk]));
336
+ continue;
337
+ }
338
+ // Last-resort fallback: drop the token's text in raw.
339
+ out.push(tk.text ?? '');
340
+ }
341
+ return out.join('');
342
+ }
225
343
  /**
226
344
  * Singleton — caching is fine since options bind to the active skin
227
345
  * via paint callbacks (which read getSkinEngine() each call).
@@ -246,7 +364,7 @@ function getReplyRenderer() {
246
364
  // need for the 4-tier hierarchy. The prototype-level `renderer.heading`
247
365
  // override below owns the depth extraction + tier selection end-to-end.
248
366
  // marked-terminal's stripped-args call path never reaches our callback.
249
- hr: () => paint('muted')('─'.repeat(Math.min(process.stdout.columns ?? 80, 100) - 4)) + '\n',
367
+ hr: () => paint('muted')('─'.repeat((0, frame_1.getBodyWidth)())) + '\n',
250
368
  listitem: renderListItem,
251
369
  paragraph: (text) => `${text}\n\n`,
252
370
  // v4.1.3-essentials: bold renders as ANSI bold + underline
@@ -274,7 +392,12 @@ function getReplyRenderer() {
274
392
  link: (assembled) => paint('accent')(assembled),
275
393
  href: paint('accent'),
276
394
  text: (text) => text,
277
- width: Math.min(process.stdout.columns ?? 80, 100),
395
+ // v4.1.4 reply-quality polish: marked-terminal's `width` is the
396
+ // *outer* canvas it formats into. Frame-aware body width keeps the
397
+ // tables / hr / hard-wrap targets inside our gutter envelope.
398
+ // `reflowText: false` (below) stays off — we own prose wrap via
399
+ // frame.wrap() in the display layer, not here.
400
+ width: (0, frame_1.getBodyWidth)(),
278
401
  showSectionPrefix: false,
279
402
  reflowText: false,
280
403
  tab: 2,
@@ -417,17 +540,32 @@ function getReplyRenderer() {
417
540
  const tok = body;
418
541
  isOrdered = tok.ordered === true;
419
542
  startNum = typeof tok.start === 'number' ? tok.start : 1;
420
- // marked v15: renderer instance has a `parser` field pointing
421
- // back to the Parser; `Parser.parse(tokens)` walks the token
422
- // tree dispatching back to renderer methods (including this
423
- // very `list` override for nested lists, which is what makes
424
- // the depth counter increment properly).
543
+ // v4.1.4 reply-quality polish Fix C (token-type dispatch).
544
+ //
545
+ // Prior implementation called `parser.parse(it.tokens)` and let
546
+ // marked's block-parser dispatch each token. For tight-list items
547
+ // marked v15 wraps the item's content in a `text`-type outer
548
+ // token whose `.tokens` array holds the actual inline tokens
549
+ // (strong, em, codespan…). `parser.parse` dispatched the outer
550
+ // wrapper to `renderer.text` (our `opts.text` = identity), which
551
+ // returned the RAW raw `**bold**` source string — never recursing
552
+ // into the inline children. Result: literal asterisks in every
553
+ // bullet that contained inline emphasis.
554
+ //
555
+ // Fix: walk each top-level token by type. Tight-list items have
556
+ // a `text` wrapper → use `parseInline` on its nested tokens to
557
+ // expand strong/em/codespan. Loose-list items have block-level
558
+ // `paragraph`/`list`/`code` tokens → those need block-level
559
+ // recursion (delegates back to our list override for nested
560
+ // lists, preserving the depth counter).
561
+ //
562
+ // Confirmed against marked v15 token shapes from `marked.lexer`
563
+ // (see scripts/smoke-issue-c-tokens.ts).
425
564
  const parser = this.parser;
426
565
  items = (tok.items ?? []).map((it) => {
427
- if (it.tokens && parser?.parse) {
428
- return parser.parse(it.tokens);
429
- }
430
- return it.text ?? '';
566
+ if (!parser)
567
+ return it.text ?? '';
568
+ return renderListItemTokens(it, parser);
431
569
  });
432
570
  }
433
571
  else {
@@ -485,7 +623,21 @@ function getReplyRenderer() {
485
623
  // if other code transiently swaps the renderer.
486
624
  marked_1.marked.setOptions({ renderer: renderer });
487
625
  const out = marked_1.marked.parse(text);
488
- return typeof out === 'string' ? out : String(out);
626
+ const raw = typeof out === 'string' ? out : String(out);
627
+ // v4.1.4 Part 1.6 Issue I — collapse excess vertical spacing.
628
+ //
629
+ // Our `opts.paragraph` callback emits `text\n\n`, our
630
+ // `renderCodeBlock` ends with `\n\n`, and marked-terminal's
631
+ // outer block dispatch ALSO emits `\n\n` between adjacent
632
+ // blocks. Result: 4 newlines (3 visible blank lines) between
633
+ // paragraphs, after code blocks, between paragraphs and lists.
634
+ // Root-cause fix would require auditing marked-terminal's
635
+ // between-block separator across every override (risk-prone).
636
+ // Band-aid: collapse any run of 3+ newlines down to exactly 2
637
+ // (= one blank line). Mechanically safe — can only REMOVE
638
+ // excess whitespace, never add bad spacing. Existing single-
639
+ // blank-line gaps pass through unchanged.
640
+ return normalizeBlankLines(raw);
489
641
  }
490
642
  catch {
491
643
  return text;
@@ -494,6 +646,24 @@ function getReplyRenderer() {
494
646
  };
495
647
  return cachedRenderer;
496
648
  }
649
+ /**
650
+ * v4.1.4 Part 1.6 Issue I — collapse runs of 3+ consecutive newlines
651
+ * down to exactly 2 (a single blank line). Exported for unit-test
652
+ * access; pure with no side effects.
653
+ *
654
+ * Confirmed via `scripts/smoke-issue-i-spacing.ts`:
655
+ * - "A\n\n\n\nB" → "A\n\nB" (2 paras → 1 blank line)
656
+ * - "A\n\n\n\n\nB" → "A\n\nB" (3+ blanks all collapse)
657
+ * - "A\n\nB" → "A\n\nB" (already correct, unchanged)
658
+ * - "A\nB" → "A\nB" (single newline preserved)
659
+ * - "A\n" → "A\n" (trailing pass-through)
660
+ *
661
+ * Does NOT touch the list-under-padding case (lists ending with a
662
+ * single `\n` before a paragraph) — that's a v4.1.5 follow-up.
663
+ */
664
+ function normalizeBlankLines(text) {
665
+ return text.replace(/\n{3,}/g, '\n\n');
666
+ }
497
667
  /** Test reset — drops the cached renderer so a skin change picks up. */
498
668
  function _resetForTests() {
499
669
  cachedRenderer = null;
@@ -49,9 +49,15 @@ const DEFAULT_SKIN = {
49
49
  error: [0xf4, 0x47, 0x47],
50
50
  warn: [0xff, 0xc1, 0x07],
51
51
  success: [0x4c, 0xaf, 0x50],
52
- // v4.1.3-repl-polish: muted is now true grey (#888) so it reads as
53
- // genuinely secondary. The soft-cyan that was here moved to 'session'.
54
- muted: [0x88, 0x88, 0x88],
52
+ // v4.1.4 reply-quality polish: muted shifts from neutral grey
53
+ // (#888888) to warm Aiden-tinted dim (#b8a89a). Mid-grey at +56
54
+ // brightness on red/green channels with a slight cool-down on
55
+ // blue, putting muted in the same warm family as brand orange
56
+ // (#FF6B35) without competing with it. Reads as "intentional
57
+ // dim" rather than washed-out terminal grey. Used by tool-trail
58
+ // gutter, status footer, code-block rail, blockquote rail, and
59
+ // display.dim() — surfaces the user reads constantly.
60
+ muted: [0xb8, 0xa8, 0x9a],
55
61
  heading: BRAND_ORANGE,
56
62
  // v4.1.3-repl-polish: session = soft cyan (ex-muted); used for IDs
57
63
  // and the session-end card header labels.
@@ -84,7 +90,12 @@ const LIGHT_SKIN = {
84
90
  error: [0xb0, 0x10, 0x10],
85
91
  warn: [0x80, 0x60, 0x00],
86
92
  success: [0x1b, 0x5e, 0x20],
87
- muted: [0x60, 0x60, 0x60],
93
+ // v4.1.4 reply-quality polish: proportional warm-shift for the
94
+ // light skin too. Was neutral #606060; new value #7a6e5e keeps the
95
+ // dark-on-light contrast budget but adds the same warm tint as the
96
+ // default skin's muted so themed surfaces feel coherent across
97
+ // skin switches.
98
+ muted: [0x7a, 0x6e, 0x5e],
88
99
  heading: [0xc4, 0x42, 0x10],
89
100
  session: [0x00, 0x55, 0x88],
90
101
  degraded: [0x80, 0x60, 0x00],
@@ -29,9 +29,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.TOOL_PRIMARY_ARG = void 0;
30
30
  exports.buildToolPreview = buildToolPreview;
31
31
  /**
32
- * Map of tool-name → name of the property in `args` that should render
33
- * as the at-a-glance preview. Stable contract; tests assert specific
34
- * entries.
32
+ * Map of tool-name → preview extractor (string key OR function).
33
+ * Stable contract; tests assert specific entries.
35
34
  */
36
35
  exports.TOOL_PRIMARY_ARG = {
37
36
  // ── terminal / execution ─────────────────────────────────────────────
@@ -70,6 +69,16 @@ exports.TOOL_PRIMARY_ARG = {
70
69
  skill_view: 'name',
71
70
  skill_manage: 'action',
72
71
  skills_list: '',
72
+ // v4.1.5 Phase 1d (Q-Q1-a) — registry introspection tool. Args
73
+ // shape: `{ toolName: 'web_search' }`. The agent uses this to
74
+ // discover unfamiliar tool schemas during planning. Surface the
75
+ // target tool name so the trail row (when not suppressed via
76
+ // TRAIL_HIDE_TOOLS) reads as the introspected tool, not raw JSON.
77
+ // Note: most callers see this tool suppressed entirely from the
78
+ // visible trail via the TRAIL_HIDE_TOOLS set in display.ts; the
79
+ // extractor exists for code paths that DON'T suppress (verbose
80
+ // mode, log-file capture).
81
+ lookup_tool_schema: 'toolName',
73
82
  // ── sessions ─────────────────────────────────────────────────────────
74
83
  session_search: 'query',
75
84
  session_list: '',
@@ -100,6 +109,37 @@ exports.TOOL_PRIMARY_ARG = {
100
109
  media_transport: 'target',
101
110
  media_key: 'action',
102
111
  app_input: 'app',
112
+ // ── v4.1.4 Phase 3b' (Issue H) ───────────────────────────────────────
113
+ // app_launch needs custom logic: when `app === 'explorer.exe'` the
114
+ // binary is just the URI dispatcher and the meaningful target is in
115
+ // `args[0]` (e.g. 'spotify:track/...'). Surface the protocol scheme
116
+ // ('spotify') rather than the dispatch binary. Falls through to the
117
+ // app name for normal exe launches.
118
+ app_launch: (args) => {
119
+ if (!args || typeof args !== 'object')
120
+ return '';
121
+ const a = args;
122
+ const appRaw = typeof a.app === 'string' ? a.app.trim() : '';
123
+ // URI-protocol case: explorer.exe + 'scheme:...' in args[0].
124
+ if (appRaw.toLowerCase() === 'explorer.exe' && Array.isArray(a.args)) {
125
+ const first = a.args[0];
126
+ if (typeof first === 'string' && first.length > 0) {
127
+ // Scheme requires ≥2 chars so Windows drive letters
128
+ // (`C:/path`) don't mis-detect as the scheme `C`. Real URI
129
+ // schemes (spotify, vscode, http, file, etc.) are all
130
+ // multi-char by RFC.
131
+ const m = first.match(/^([A-Za-z][A-Za-z0-9+.-]+):/);
132
+ if (m)
133
+ return m[1]; // 'spotify:track/...' → 'spotify'
134
+ return first; // No protocol — surface the raw arg
135
+ }
136
+ }
137
+ return appRaw;
138
+ },
139
+ // Clipboard write — the actual text being copied is the meaningful
140
+ // target. Reads have no args worth showing (empty schema).
141
+ clipboard_write: 'text',
142
+ clipboard_read: '',
103
143
  };
104
144
  /**
105
145
  * Maximum visible characters for the preview value. Long commands /
@@ -121,28 +161,47 @@ function buildToolPreview(toolName, args) {
121
161
  if (!Object.prototype.hasOwnProperty.call(exports.TOOL_PRIMARY_ARG, toolName)) {
122
162
  return null;
123
163
  }
124
- const argKey = exports.TOOL_PRIMARY_ARG[toolName];
125
- if (argKey === '')
126
- return '';
127
- if (!args || typeof args !== 'object')
128
- return '';
129
- const raw = args[argKey];
130
- if (raw === undefined || raw === null)
131
- return '';
164
+ const extractor = exports.TOOL_PRIMARY_ARG[toolName];
165
+ // v4.1.4 Phase 3b' (Issue H1): function extractor path. Used by
166
+ // tools whose preview can't be expressed as a single key lookup
167
+ // (e.g. app_launch with URI-protocol routing through explorer.exe).
132
168
  let str;
133
- if (typeof raw === 'string') {
134
- str = raw;
135
- }
136
- else if (typeof raw === 'number' || typeof raw === 'boolean') {
137
- str = String(raw);
138
- }
139
- else {
169
+ if (typeof extractor === 'function') {
140
170
  try {
141
- str = JSON.stringify(raw);
171
+ const out = extractor(args);
172
+ str = typeof out === 'string' ? out : '';
142
173
  }
143
174
  catch {
175
+ // Extractor threw — degrade to empty preview rather than crash
176
+ // the tool-row render. The tool name + state cluster still
177
+ // carries enough info.
178
+ str = '';
179
+ }
180
+ }
181
+ else {
182
+ // String-key path (legacy, unchanged behaviour).
183
+ const argKey = extractor;
184
+ if (argKey === '')
185
+ return '';
186
+ if (!args || typeof args !== 'object')
187
+ return '';
188
+ const raw = args[argKey];
189
+ if (raw === undefined || raw === null)
190
+ return '';
191
+ if (typeof raw === 'string') {
192
+ str = raw;
193
+ }
194
+ else if (typeof raw === 'number' || typeof raw === 'boolean') {
144
195
  str = String(raw);
145
196
  }
197
+ else {
198
+ try {
199
+ str = JSON.stringify(raw);
200
+ }
201
+ catch {
202
+ str = String(raw);
203
+ }
204
+ }
146
205
  }
147
206
  // Collapse whitespace so multi-line commands stay on one preview row.
148
207
  str = str.replace(/\s+/g, ' ').trim();
@@ -1388,7 +1388,13 @@ exports.TOOLS = {
1388
1388
  return { success: false, output: '', error: `No research results for: ${topic}` };
1389
1389
  }
1390
1390
  const combined = results.join('\n\n');
1391
- console.log(`[deep_research] Complete: ${combined.length} chars across ${results.length} passes`);
1391
+ // v4.1.5 Issue O gated behind AIDEN_DEBUG_WEB to match the
1392
+ // webSearch.ts debug-helper convention. Default off; power users
1393
+ // export the env var to see the research chain.
1394
+ if (process.env.AIDEN_DEBUG_WEB === '1') {
1395
+ // eslint-disable-next-line no-console
1396
+ console.log(`[deep_research] Complete: ${combined.length} chars across ${results.length} passes`);
1397
+ }
1392
1398
  return { success: true, output: combined.slice(0, 15000) };
1393
1399
  },
1394
1400
  // Activate a specialist agent persona — actual synthesis happens in respond phase
@@ -103,6 +103,10 @@ class AidenAgent {
103
103
  this.onCompression = opts.onCompression;
104
104
  this.refreshMemorySnapshot = opts.refreshMemorySnapshot;
105
105
  this.onMemoryRefresh = opts.onMemoryRefresh;
106
+ // v4.1.5 Issue K — phase hooks (all optional, fire defensively).
107
+ this.onMemoryRefreshStart = opts.onMemoryRefreshStart;
108
+ this.onPromptBuilt = opts.onPromptBuilt;
109
+ this.onProviderRequestStart = opts.onProviderRequestStart;
106
110
  this.lookupSkillRequiredTools = opts.lookupSkillRequiredTools;
107
111
  // Phase v4.1.2-slice3: optional health registry (constructor-
108
112
  // injected per the slice3 decision tree — no singleton). When
@@ -386,6 +390,14 @@ class AidenAgent {
386
390
  // / 'user' need a snapshot refresh first.
387
391
  const needsSnapshot = this.memoryDirty.has('memory') || this.memoryDirty.has('user');
388
392
  if (needsSnapshot && this.refreshMemorySnapshot) {
393
+ // v4.1.5 Issue K — fire BEFORE the file I/O so the display layer
394
+ // can switch the activity verb to "refreshing memory" while the
395
+ // read is in flight. Defensive try/catch so a misbehaving hook
396
+ // never blocks the refresh.
397
+ try {
398
+ this.onMemoryRefreshStart?.();
399
+ }
400
+ catch { /* defensive */ }
389
401
  let snapshot;
390
402
  try {
391
403
  snapshot = await this.refreshMemorySnapshot();
@@ -410,6 +422,21 @@ class AidenAgent {
410
422
  if (this.cachedSystemPrompt !== null)
411
423
  return this.cachedSystemPrompt;
412
424
  this.cachedSystemPrompt = await this.promptBuilder.build(this.promptBuilderOptions);
425
+ // v4.1.5 Issue K — fire AFTER the prompt has been assembled, with
426
+ // cardinality so the display layer can surface "preparing prompt:
427
+ // N tools, M skills" or similar. Only fires when the cache MISSED
428
+ // (which is what made us actually build); cached returns skip the
429
+ // hook because nothing was prepared this turn. Defensive try/catch.
430
+ if (this.onPromptBuilt) {
431
+ try {
432
+ this.onPromptBuilt({
433
+ tools: this.tools.length,
434
+ skills: this.promptBuilderOptions.skillsList?.length ?? 0,
435
+ memoryFacts: countMemoryFacts(this.promptBuilderOptions.memorySnapshot),
436
+ });
437
+ }
438
+ catch { /* defensive */ }
439
+ }
413
440
  return this.cachedSystemPrompt;
414
441
  }
415
442
  async narrowTools(userMsg, history) {
@@ -629,6 +656,18 @@ class AidenAgent {
629
656
  */
630
657
  async callProvider(messages, tools, runOptions) {
631
658
  const wantStream = runOptions.stream === true && typeof this.provider.callStream === 'function';
659
+ // v4.1.5 Issue K — fire just before the HTTP request opens, so the
660
+ // display layer can transition the activity verb from local-prep
661
+ // ("preparing prompt", "selecting tools") to a network verb
662
+ // ("calling provider"). The wait for TTFT (time-to-first-token) is
663
+ // the longest gap in most turns and is what the wave bar covers.
664
+ // Fires for both streaming and non-streaming paths — caller may use
665
+ // it to add a one-shot indicator on non-streaming providers too.
666
+ // Defensive try/catch (a misbehaving hook must not block dispatch).
667
+ try {
668
+ this.onProviderRequestStart?.(this.providerId);
669
+ }
670
+ catch { /* defensive */ }
632
671
  if (!wantStream) {
633
672
  return this.provider.call({ messages, tools });
634
673
  }
@@ -650,6 +689,15 @@ class AidenAgent {
650
689
  else if (evt.type === 'tool_call') {
651
690
  runOptions.onToolCallStart?.(evt.toolCall);
652
691
  }
692
+ else if (evt.type === 'progress') {
693
+ // v4.1.4 Part 1.6 — drive the per-turn token progress bar.
694
+ // Defensive try/catch — a misbehaving display sink must not
695
+ // tear down the stream consumer.
696
+ try {
697
+ runOptions.onProgress?.(evt.outputTokens, evt.maxTokens);
698
+ }
699
+ catch { /* progress sink errors don't block streaming */ }
700
+ }
653
701
  else if (evt.type === 'done') {
654
702
  finalOutput = evt.output;
655
703
  }
@@ -662,6 +710,30 @@ class AidenAgent {
662
710
  }
663
711
  exports.AidenAgent = AidenAgent;
664
712
  // ── Free helpers ────────────────────────────────────────────────────────
713
+ /**
714
+ * v4.1.5 Issue K — best-effort count of "memory facts" from a
715
+ * MemorySnapshot. Counts markdown bullet-list lines (`- `) in both
716
+ * MEMORY.md and USER.md. This is a fuzzy proxy — the agent stores
717
+ * facts as bullets by convention but free-form prose can also carry
718
+ * fact-like content. Surfaced verbatim to the display layer; treat as
719
+ * "approximately N items in the persistent memory file" rather than
720
+ * a precise inventory.
721
+ */
722
+ function countMemoryFacts(snapshot) {
723
+ if (!snapshot || typeof snapshot !== 'object')
724
+ return 0;
725
+ const s = snapshot;
726
+ let count = 0;
727
+ for (const md of [s.memoryMd, s.userMd]) {
728
+ if (typeof md !== 'string' || md.length === 0)
729
+ continue;
730
+ for (const line of md.split('\n')) {
731
+ if (line.trim().startsWith('- '))
732
+ count += 1;
733
+ }
734
+ }
735
+ return count;
736
+ }
665
737
  function lastUserMessageContent(history) {
666
738
  for (let i = history.length - 1; i >= 0; i--) {
667
739
  const m = history[i];