aiden-runtime 4.1.4 → 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.
@@ -18,12 +18,13 @@
18
18
  *
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.TOOL_ROW_ARG_CAP = exports.TOOL_ROW_NAME_PAD = exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
21
+ exports.TRAIL_HIDE_TOOLS = exports.TOOL_ROW_ARG_CAP = exports.TOOL_ROW_NAME_PAD = exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
22
22
  exports.iconForTool = iconForTool;
23
23
  exports.detectConfiguredChannels = detectConfiguredChannels;
24
24
  exports.summarizeConfiguredChannels = summarizeConfiguredChannels;
25
25
  exports.summarizeChannelState = summarizeChannelState;
26
26
  exports.voiceIndicator = voiceIndicator;
27
+ exports.makeNoOpToolRowHandle = makeNoOpToolRowHandle;
27
28
  exports.previewToolArgs = previewToolArgs;
28
29
  exports.verbForActivity = verbForActivity;
29
30
  exports.isPreFramedLine = isPreFramedLine;
@@ -772,7 +773,21 @@ class Display {
772
773
  * content below MUST call `pause()` first; otherwise their content
773
774
  * lands on the indicator line and the next tick clobbers it.
774
775
  */
775
- activityIndicator(initialVerb = 'thinking') {
776
+ /**
777
+ * v4.1.5 Issue K — wave-bar option.
778
+ *
779
+ * When `opts.waveBar === true` (DEFAULT), the indicator paints a
780
+ * second row BELOW the verb line — a 10-cell `▰▱` snake-scroll
781
+ * heartbeat that gives visible motion during long pre-first-token
782
+ * gaps even when the verb doesn't change. The bar is NOT progress:
783
+ * it's a constant-cadence heartbeat (250ms shared with the dot
784
+ * pulse), explicitly not a percentage indicator.
785
+ *
786
+ * Pass `{ waveBar: false }` for back-compat with v4.1.4 tests that
787
+ * assert single-row geometry. Production callers (chatSession) get
788
+ * the wave bar by default.
789
+ */
790
+ activityIndicator(initialVerb = 'thinking', opts = {}) {
776
791
  const sk = this.skin;
777
792
  const out = this.out;
778
793
  const isTty = !!out.isTTY;
@@ -799,6 +814,13 @@ class Display {
799
814
  // feedback; a separate bottom-of-screen footer can be added in
800
815
  // v4.1.5 if wanted, but it must NOT be glued to the indicator.
801
816
  const glyph = sk.applyColors('▲', 'brand');
817
+ // v4.1.5 Issue K — wave-bar state. Snake-scroll: a 3-cell `▰`
818
+ // block slides across 10 cells, wrapping at the right edge. Same
819
+ // 250ms tick as the verb dot pulse — one timer drives both rows.
820
+ const waveBarEnabled = opts.waveBar !== false; // default true
821
+ const WAVE_CELLS = 10;
822
+ const WAVE_BLOCK = 3;
823
+ let waveFrame = 0;
802
824
  const buildLine = () => {
803
825
  const dots = '.'.repeat(dotFrame); // 0..3 dots
804
826
  const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
@@ -808,14 +830,92 @@ class Display {
808
830
  // `▲ {verb}{dots-padded-to-3}{elapsed?}`
809
831
  return `${glyph} ${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
810
832
  };
833
+ /**
834
+ * v4.1.5 Issue K — render the wave-bar row. A 3-cell `▰` block at
835
+ * positions `[waveFrame, waveFrame+1, waveFrame+2]` mod 10. The
836
+ * filled cells paint brand orange, empty cells paint warm-muted.
837
+ * Same width + glyph set as the token progress bar so the two
838
+ * rows feel like a coherent palette (one is heartbeat, the other
839
+ * is real progress).
840
+ *
841
+ * Heartbeat semantics: this is NOT progress. The wave moves at a
842
+ * constant 250ms cadence regardless of any backend metric. It
843
+ * exists purely so the user sees motion during the unobservable
844
+ * TTFT (time-to-first-token) wait. The verb row above carries
845
+ * any real lifecycle signal via `setVerb()`.
846
+ */
847
+ const buildWave = () => {
848
+ // v4.1.5 Phase 1d (Q-P1) — glyph palette switch. Was `▰`/`▱`
849
+ // (U+25B0/B1, Geometric Shapes) which legacy Windows console
850
+ // fonts render as tofu. Now `▓`/`░` (U+2593/91, Block Elements
851
+ // — in CP437, universally supported). Matches the existing
852
+ // statusFooter chrome that's shipped since v3 without ever
853
+ // being garbled.
854
+ const filled = new Set();
855
+ for (let i = 0; i < WAVE_BLOCK; i += 1) {
856
+ filled.add((waveFrame + i) % WAVE_CELLS);
857
+ }
858
+ // Render cells in order so the snake-scroll visually slides:
859
+ // we paint cell-by-cell with the right color, joined into one
860
+ // string. ANSI runs reset per cell — slight overhead but keeps
861
+ // glyph order true to position. Brand orange filled, warm-muted
862
+ // empty.
863
+ const cells = [];
864
+ for (let c = 0; c < WAVE_CELLS; c += 1) {
865
+ cells.push(filled.has(c)
866
+ ? sk.applyColors('▓', 'brand')
867
+ : sk.applyColors('░', 'muted'));
868
+ }
869
+ return cells.join('');
870
+ };
871
+ // v4.1.5 Part 1a — Issue M (Windows ConPTY buffering fix).
872
+ //
873
+ // Prior pattern wrote `\r\x1b[K{indicator}` with NO trailing
874
+ // newline. On Windows ConPTY, `process.stdout` buffers no-newline
875
+ // writes — none of the 60 indicator ticks during a 15s gap
876
+ // actually rendered. The final reply's `\n` chars eventually
877
+ // flushed the buffer, but by then the indicator's stop()-erase
878
+ // had also been buffered + flushed, so the user saw 15s of blank
879
+ // followed by the reply dumping all at once.
880
+ //
881
+ // Fix: indicator OWNS one terminal row. Every write that paints
882
+ // the indicator ends with `\n`, which forces a flush on every
883
+ // platform. The cursor sits on the LINE BELOW the indicator
884
+ // while it's running (one visible empty row gap). When the
885
+ // indicator stops/pauses, we walk back UP to the indicator's
886
+ // row and erase it — the cursor then sits at col 0 of that
887
+ // (now empty) row, ready for the caller to write whatever
888
+ // content follows (header, tool row, stream output).
889
+ //
890
+ // ANSI primitives:
891
+ // `\x1b[1A` — cursor up 1 line
892
+ // `\x1b[2K` — erase the whole current line
893
+ // Sequence on tick: walk up → erase → paint → `\n` → cursor below.
894
+ // Sequence on erase: walk up → erase (no newline). Cursor on the
895
+ // now-empty indicator row, ready for caller.
896
+ const ANSI_UP_ERASE = '\x1b[1A\x1b[2K';
811
897
  const renderTick = () => {
812
898
  if (stopped || paused || !isTty)
813
899
  return;
814
900
  dotFrame = (dotFrame + 1) % 4;
815
- // `\r\x1b[K`carriage return + clear line then write the
816
- // fresh indicator. No newline at end: cursor stays at end of
817
- // the indicator line, ready for the next overwrite.
818
- out.write(`\r\x1b[K${buildLine()}`);
901
+ // v4.1.5 Issue K — wave snake-scroll advances 1 cell per tick.
902
+ // Same 250ms cadence as the dot pulse, so both rows move in
903
+ // visible lockstep. Modulo WAVE_CELLS wraps the leading block
904
+ // back to the left edge.
905
+ waveFrame = (waveFrame + 1) % WAVE_CELLS;
906
+ if (waveBarEnabled) {
907
+ // 2-row layout: walk up TWO rows (two separate up-1+erase
908
+ // sequences, which keeps the `\x1b[1A\x1b[2K` substring
909
+ // assertion-compatible), repaint both, drop newlines so the
910
+ // cursor lands on the row below the wave bar.
911
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}` +
912
+ `${buildLine()}\n` +
913
+ `${buildWave()}\n`);
914
+ }
915
+ else {
916
+ // Single-row layout (back-compat with v4.1.4 tests).
917
+ out.write(`${ANSI_UP_ERASE}${buildLine()}\n`);
918
+ }
819
919
  };
820
920
  const startTick = () => {
821
921
  if (stopped || !isTty || tickTimer !== null)
@@ -829,12 +929,38 @@ class Display {
829
929
  }
830
930
  };
831
931
  const eraseLine = () => {
832
- if (isTty && printed)
833
- out.write('\r\x1b[K');
932
+ // Walk up to the indicator's row(s) + erase. Cursor lands at
933
+ // col 0 of the (now empty) verb row. NO trailing newline here:
934
+ // the caller is about to write content on this row, and
935
+ // whatever they write will include their own `\n` to flush
936
+ // the buffer. If we emitted `\n` here, we'd leave a phantom
937
+ // blank row before the caller's content.
938
+ //
939
+ // v4.1.5 Issue K — with wave bar enabled, walk up 2 rows (two
940
+ // up-1+erase sequences). Without the bar, walk up 1 row.
941
+ if (!isTty || !printed)
942
+ return;
943
+ if (waveBarEnabled) {
944
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}`);
945
+ }
946
+ else {
947
+ out.write(ANSI_UP_ERASE);
948
+ }
834
949
  };
835
- // Initial paint — only on TTY.
950
+ // Initial paint — only on TTY. Indicator + `\n` so the buffer
951
+ // flushes and the cursor sits on the row below, ready for the
952
+ // first tick to walk back up.
953
+ //
954
+ // v4.1.5 Issue K — when wave bar is enabled, paint TWO rows:
955
+ // verb row + wave row, each with trailing `\n`. Cursor lands on
956
+ // the row below the wave bar. The first tick will walk up 2.
836
957
  if (isTty) {
837
- out.write(buildLine());
958
+ if (waveBarEnabled) {
959
+ out.write(`${buildLine()}\n${buildWave()}\n`);
960
+ }
961
+ else {
962
+ out.write(`${buildLine()}\n`);
963
+ }
838
964
  printed = true;
839
965
  startTick();
840
966
  }
@@ -861,9 +987,18 @@ class Display {
861
987
  return;
862
988
  // Caller has just finished writing its own content (typically
863
989
  // ending with `\n`), so the cursor is on a fresh line below
864
- // whatever was there. Render the indicator there and arm the
865
- // tick again.
866
- out.write(buildLine());
990
+ // whatever was there. Paint the indicator + `\n` to claim the
991
+ // current row(s) and leave the cursor on the row below — same
992
+ // invariant the initial paint and tick maintain. Trailing `\n`
993
+ // also flushes Windows ConPTY buffering (Issue M).
994
+ //
995
+ // v4.1.5 Issue K — repaint BOTH rows when wave bar enabled.
996
+ if (waveBarEnabled) {
997
+ out.write(`${buildLine()}\n${buildWave()}\n`);
998
+ }
999
+ else {
1000
+ out.write(`${buildLine()}\n`);
1001
+ }
867
1002
  printed = true;
868
1003
  startTick();
869
1004
  },
@@ -894,6 +1029,31 @@ class Display {
894
1029
  // completion so each line in the log carries the final state — no
895
1030
  // ANSI cursor games on a dumb sink.
896
1031
  toolRow(name, args) {
1032
+ // v4.1.5 Phase 1d (Q-Q2-a) — TRAIL_HIDE_TOOLS suppression.
1033
+ //
1034
+ // Some tools are pure agent plumbing — the model calls them to
1035
+ // introspect its own registry, not to do user-visible work.
1036
+ // `lookup_tool_schema` is the canonical case: during planning
1037
+ // the agent may invoke it 30+ times to discover unfamiliar tool
1038
+ // shapes. Each call is a sub-millisecond in-memory lookup, but
1039
+ // they flood the visible trail with noise that obscures the
1040
+ // actual user-relevant tool calls.
1041
+ //
1042
+ // Short-circuit: hidden tools get a NO-OP handle that satisfies
1043
+ // the `ToolRowHandle` contract (ok/fail/degraded/retry/blocked/
1044
+ // emptyRetry/emptyFail all defined but write nothing). The
1045
+ // execution path itself is unaffected — the agent still calls
1046
+ // the tool, the planner / skill-enforcement trackers still
1047
+ // record it. Only the visual row is suppressed.
1048
+ //
1049
+ // CRITICAL invariant: `setBeforeFirstToolHook` is fired by
1050
+ // callbacks.ts BEFORE `toolRow()` is called (see callbacks.ts
1051
+ // onToolCall 'before' branch), so `turnHadTools` flips even for
1052
+ // hidden tools. The separator logic stays correct regardless of
1053
+ // whether ONLY hidden tools fired this turn.
1054
+ if (exports.TRAIL_HIDE_TOOLS.has(name)) {
1055
+ return makeNoOpToolRowHandle();
1056
+ }
897
1057
  const sk = this.skin;
898
1058
  // ── Build the fixed left portion (icon + verb + detail) ────────────
899
1059
  // v4.1.3-repl-polish: icons default ON; set AIDEN_UI_ICONS=0 to
@@ -995,8 +1155,31 @@ class Display {
995
1155
  writeFinal(`ok ${formatToolDuration(durationMs)} after ${retries} ${retries === 1 ? 'retry' : 'retries'}`, 'warn');
996
1156
  }
997
1157
  else {
998
- // Clean successSILENT. Erase on TTY; emit nothing on non-TTY.
999
- eraseLast();
1158
+ // v4.1.5 Issue N persistent tool trail in scrollback.
1159
+ //
1160
+ // Prior behaviour: silent erase on clean success (`eraseLast()`
1161
+ // with no replacement write). Tool rows for successful tools
1162
+ // vanished, leaving only the markdown reply visible afterward.
1163
+ // The user couldn't see WHAT actions Aiden took unless a tool
1164
+ // failed or degraded.
1165
+ //
1166
+ // Fix: replace the silent erase with a completed-state row
1167
+ // painted entirely in warm-muted (`#b8a89a` from v4.1.4). The
1168
+ // duration suffix replaces the live `running Ns…` chrome; the
1169
+ // whole row reads "done" via reduced visual weight. Failed /
1170
+ // degraded / retry outcomes keep their existing coloured paint
1171
+ // (error red, degraded yellow, warn amber) — only clean success
1172
+ // shifts from "silent" to "muted-persistent."
1173
+ //
1174
+ // The persistence mechanism is the existing `writeFinal` path:
1175
+ // it walks up + erases the running row, then writes the final
1176
+ // row with trailing `\n`. The row sits in scrollback because
1177
+ // `streamComplete` rerenders only the post-tool stream chunk
1178
+ // (via `streamLineCount` which was reset to 0 inside
1179
+ // `commitStreamChunk` before this row wrote). No additional
1180
+ // isolation machinery needed — already verified by 13/13
1181
+ // `smoke-stream-rerender.ts` regressions.
1182
+ writeFinal(formatToolDuration(durationMs), 'muted');
1000
1183
  }
1001
1184
  },
1002
1185
  fail(durationMs, retries = 0) {
@@ -1698,6 +1881,56 @@ function renderRmsBar(rms) {
1698
1881
  exports.TOOL_ROW_NAME_PAD = toolTrail_1.TRAIL_VERB_PAD;
1699
1882
  /** @deprecated Use TRAIL_DETAIL_CAP. */
1700
1883
  exports.TOOL_ROW_ARG_CAP = toolTrail_1.TRAIL_DETAIL_CAP;
1884
+ /**
1885
+ * v4.1.5 Phase 1d (Q-Q2-a) — names of tools that should be SUPPRESSED
1886
+ * from the visible tool-trail row, even though they still execute
1887
+ * normally through the agent loop.
1888
+ *
1889
+ * The canonical case is `lookup_tool_schema`: the agent calls it
1890
+ * during planning to introspect tool registry entries (in-memory
1891
+ * registry get, sub-millisecond per call). On complex prompts the
1892
+ * model may fire it 30+ times in a row, flooding the visible trail
1893
+ * with rows that don't represent user-meaningful work. Suppressing
1894
+ * them keeps the trail focused on the tools that did real work
1895
+ * (web_search, file_read, etc.).
1896
+ *
1897
+ * Suppression happens at `Display.toolRow()` entry — it returns a
1898
+ * no-op handle that satisfies the `ToolRowHandle` contract but
1899
+ * never writes to stdout. The agent's `callbacks.onToolCall`
1900
+ * dispatch is unchanged: `setBeforeFirstToolHook` still fires (so
1901
+ * `turnHadTools` flips for the separator-emission logic), and
1902
+ * skill-enforcement / honesty-trace tracking still records the
1903
+ * call. Only the visual row is hidden.
1904
+ *
1905
+ * Exported as a `Set` so callers can mutate at runtime if they
1906
+ * need to hide additional tools (e.g. user customization, MCP
1907
+ * plumbing tools). Mutation-of-shared-state is intentional — there's
1908
+ * no per-session config plumbing for "trail hidden tools" yet, so
1909
+ * the env-var pattern (`AIDEN_TRAIL_HIDE=tool1,tool2`) would be the
1910
+ * v4.1.6 evolution.
1911
+ */
1912
+ exports.TRAIL_HIDE_TOOLS = new Set([
1913
+ 'lookup_tool_schema',
1914
+ ]);
1915
+ /**
1916
+ * v4.1.5 Phase 1d helper — produces a `ToolRowHandle` that satisfies
1917
+ * the contract but writes nothing. Used by hidden tools (see
1918
+ * `TRAIL_HIDE_TOOLS`) and as a safe fallback. All methods are inert.
1919
+ *
1920
+ * Pure — no side effects, no closures over Display state. Safe to
1921
+ * call from any thread / phase.
1922
+ */
1923
+ function makeNoOpToolRowHandle() {
1924
+ return {
1925
+ ok: () => { },
1926
+ fail: () => { },
1927
+ degraded: () => { },
1928
+ retry: () => { },
1929
+ blocked: () => { },
1930
+ emptyRetry: () => { },
1931
+ emptyFail: () => { },
1932
+ };
1933
+ }
1701
1934
  /**
1702
1935
  * Build a compact, single-line preview of the tool's arguments. Picks
1703
1936
  * the most informative scalar fields when the args are an object, then
@@ -69,6 +69,16 @@ exports.TOOL_PRIMARY_ARG = {
69
69
  skill_view: 'name',
70
70
  skill_manage: 'action',
71
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',
72
82
  // ── sessions ─────────────────────────────────────────────────────────
73
83
  session_search: 'query',
74
84
  session_list: '',
@@ -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
  }
@@ -671,6 +710,30 @@ class AidenAgent {
671
710
  }
672
711
  exports.AidenAgent = AidenAgent;
673
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
+ }
674
737
  function lastUserMessageContent(history) {
675
738
  for (let i = history.length - 1; i >= 0; i--) {
676
739
  const m = history[i];