@pellux/goodvibes-tui 1.20.1 → 1.21.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 (56) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/main.ts +12 -20
  36. package/src/providers/reasoning-effort-surface.ts +399 -0
  37. package/src/renderer/compositor.ts +68 -18
  38. package/src/renderer/markdown-inline.ts +143 -0
  39. package/src/renderer/markdown-table.ts +459 -0
  40. package/src/renderer/markdown.ts +6 -335
  41. package/src/renderer/model-workspace.ts +48 -5
  42. package/src/runtime/bootstrap-command-parts.ts +36 -4
  43. package/src/runtime/bootstrap-core.ts +1 -1
  44. package/src/runtime/durability-housekeeping.ts +183 -0
  45. package/src/runtime/durability-services.ts +68 -11
  46. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  47. package/src/runtime/provider-fallback.ts +16 -2
  48. package/src/runtime/recovery-decisions.ts +180 -11
  49. package/src/runtime/recovery-prompt.ts +11 -1
  50. package/src/runtime/services.ts +9 -4
  51. package/src/runtime/session-liveness-marker.ts +142 -5
  52. package/src/runtime/workstream-draft-store.ts +198 -25
  53. package/src/runtime/workstream-services.ts +17 -2
  54. package/src/shell/ui-openers.ts +1 -1
  55. package/src/version.ts +1 -1
  56. package/src/work-plans/work-plan-store.ts +334 -16
@@ -1,4 +1,12 @@
1
- import { existsSync, readFileSync, renameSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The suffix every quarantined file in this codebase gets. Exported so the
6
+ * other producer of these files (the transcript journal's corrupt-tail
7
+ * quarantine) and the reclaim sweep below all agree on one spelling.
8
+ */
9
+ export const UNRECOGNIZED_SUFFIX = '.unrecognized';
2
10
 
3
11
  /**
4
12
  * A migration function that transforms data from version N to N+1.
@@ -108,8 +116,113 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
108
116
 
109
117
  function quarantine(path: string): void {
110
118
  try {
111
- renameSync(path, `${path}.unrecognized`);
119
+ renameSync(path, `${path}${UNRECOGNIZED_SUFFIX}`);
112
120
  } catch {
113
121
  // Best-effort — if rename fails (e.g. race), proceed silently.
114
122
  }
115
123
  }
124
+
125
+ // ─── Quarantine reclaim ───────────────────────────────────────────────────────
126
+ //
127
+ // Quarantining renames a bad file out of the way so a human can inspect it,
128
+ // and until now nothing ever removed the result — every corrupt config file
129
+ // and every torn journal tail left a `.unrecognized` file behind permanently.
130
+ // Forensic value is real, so the retention window below is deliberately long,
131
+ // but "keep forever" is a leak.
132
+
133
+ /**
134
+ * How long a quarantined file is kept before it is reclaimed: 30 days
135
+ * (2_592_000_000 ms). These files exist so a person can look at what went
136
+ * wrong, so the window is a month rather than the hours-to-days retention the
137
+ * live durability artefacts get — long enough to survive a holiday, short
138
+ * enough that a recurring corruption cannot fill a disk.
139
+ */
140
+ export const QUARANTINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
141
+
142
+ /**
143
+ * Hard ceiling on quarantined files kept per swept directory, newest kept.
144
+ * The age rule alone cannot bound a fast repeating corruption (a boot loop
145
+ * quarantining the same file every restart), so a count cap runs alongside it.
146
+ */
147
+ export const QUARANTINE_MAX_FILES_PER_DIR = 50;
148
+
149
+ export interface QuarantineReapResult {
150
+ /** Quarantined files examined across every directory. */
151
+ readonly scanned: number;
152
+ /** Quarantined files deleted. */
153
+ readonly reaped: number;
154
+ }
155
+
156
+ export interface QuarantineReapOptions {
157
+ readonly now?: () => number;
158
+ /** Override the age window (tests). */
159
+ readonly maxAgeMs?: number;
160
+ /** Override the per-directory count cap (tests). */
161
+ readonly maxFilesPerDir?: number;
162
+ }
163
+
164
+ /**
165
+ * Delete `.unrecognized` quarantine files that are past the retention window,
166
+ * plus any beyond the per-directory count cap (newest kept).
167
+ *
168
+ * Each directory is scanned non-recursively; a missing or unreadable directory
169
+ * contributes nothing and is not an error. Idempotent, and safe to run
170
+ * concurrently from several processes — a file another sweeper already
171
+ * unlinked (ENOENT) counts as reclaimed rather than failing the sweep.
172
+ */
173
+ export function reapQuarantinedFiles(
174
+ directories: readonly string[],
175
+ options: QuarantineReapOptions = {},
176
+ ): QuarantineReapResult {
177
+ const now = options.now?.() ?? Date.now();
178
+ const maxAgeMs = options.maxAgeMs ?? QUARANTINE_RETENTION_MS;
179
+ const maxFilesPerDir = options.maxFilesPerDir ?? QUARANTINE_MAX_FILES_PER_DIR;
180
+
181
+ let scanned = 0;
182
+ let reaped = 0;
183
+
184
+ for (const dir of new Set(directories)) {
185
+ let names: string[];
186
+ try {
187
+ names = readdirSync(dir).filter((name) => name.endsWith(UNRECOGNIZED_SUFFIX));
188
+ } catch {
189
+ continue;
190
+ }
191
+ scanned += names.length;
192
+
193
+ const survivors: { readonly path: string; readonly mtimeMs: number }[] = [];
194
+ for (const name of names) {
195
+ const path = join(dir, name);
196
+ let mtimeMs: number;
197
+ try {
198
+ mtimeMs = statSync(path).mtimeMs;
199
+ } catch {
200
+ // Vanished under us (another sweeper) — nothing left to reclaim.
201
+ continue;
202
+ }
203
+ if (now - mtimeMs > maxAgeMs) {
204
+ if (unlinkQuarantined(path)) reaped++;
205
+ continue;
206
+ }
207
+ survivors.push({ path, mtimeMs });
208
+ }
209
+
210
+ if (survivors.length > maxFilesPerDir) {
211
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
212
+ for (const victim of survivors.slice(0, survivors.length - maxFilesPerDir)) {
213
+ if (unlinkQuarantined(victim.path)) reaped++;
214
+ }
215
+ }
216
+ }
217
+
218
+ return { scanned, reaped };
219
+ }
220
+
221
+ function unlinkQuarantined(path: string): boolean {
222
+ try {
223
+ unlinkSync(path);
224
+ return true;
225
+ } catch (err) {
226
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
227
+ }
228
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * conversation-event-navigation.ts — resolving "jump to the next/previous
3
+ * transcript event" into a history-buffer line number.
4
+ *
5
+ * Extracted from ConversationManager (core/conversation.ts), which is at its
6
+ * line-count gate, and kept structural (no SDK imports beyond the event kind)
7
+ * so the ordering rule can be tested without building a conversation.
8
+ *
9
+ * The rule both directions share: take the events of the requested kind, map
10
+ * each to the line its message was rendered at, drop the ones that were never
11
+ * rendered (a message with no registry entry), sort ascending, then wrap —
12
+ * searching forward past `currentLine` falls back to the first line, searching
13
+ * backward falls back to the last. -1 means there is nowhere to go.
14
+ */
15
+
16
+ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
17
+
18
+ /** The subset of a transcript-event-index entry this module reads. */
19
+ export interface TranscriptEventLike {
20
+ readonly kind: TranscriptEventKind;
21
+ readonly messageIndex: number;
22
+ }
23
+
24
+ export function resolveTranscriptEventLine(
25
+ events: readonly TranscriptEventLike[],
26
+ kind: TranscriptEventKind | 'all',
27
+ messageLineRegistry: readonly (number | undefined)[],
28
+ currentLine: number,
29
+ direction: 'next' | 'prev',
30
+ ): number {
31
+ const selected = kind === 'all' ? events : events.filter((event) => event.kind === kind);
32
+ if (selected.length === 0) return -1;
33
+ const lines = selected
34
+ .map((event) => messageLineRegistry[event.messageIndex] ?? -1)
35
+ .filter((line) => line >= 0)
36
+ .sort((a, b) => a - b);
37
+ if (lines.length === 0) return -1;
38
+ if (direction === 'next') {
39
+ return lines.find((line) => line > currentLine) ?? lines[0]!;
40
+ }
41
+ return [...lines].reverse().find((line) => line < currentLine) ?? lines[lines.length - 1]!;
42
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * conversation-search-expansion.ts — tracks which collapse keys transcript
3
+ * search auto-expanded so they can be restored on search close, unless the
4
+ * user explicitly acted on the block while it was open.
5
+ *
6
+ * Extracted from conversation.ts to keep that file under the architecture
7
+ * line-count gate. restoreOnto() applies restoration through a structural
8
+ * `{ setCollapsed }` shape (ConversationManager's own public setCollapsed())
9
+ * rather than importing ConversationManager itself, so this module has no
10
+ * import-cycle back to conversation.ts.
11
+ *
12
+ * The contract (see SearchManager.revealCurrentMatch and .close in
13
+ * search.ts): a single keystroke in the search field must never expand
14
+ * collapsed content — only navigating TO a match hidden inside it does, and
15
+ * that expansion is undone when search closes UNLESS the user separately
16
+ * touched the block (toggled, copied, bookmarked, saved, or bulk-expanded
17
+ * via /expand) while it sat auto-expanded. An explicit user action always
18
+ * wins over search's own bookkeeping, regardless of which happened first.
19
+ */
20
+
21
+ /** Structural shape restoreOnto() needs — matches ConversationManager's own
22
+ * public setCollapsed() without importing the class (avoids a cycle). */
23
+ export interface CollapseSettable {
24
+ setCollapsed(collapseKey: string, collapsed: boolean): void;
25
+ }
26
+
27
+ export class SearchExpansionTracker {
28
+ /**
29
+ * Collapse keys currently expanded because the user navigated to a search
30
+ * match hidden inside them. Never contains a key the user has touched
31
+ * explicitly — noteUserTouch() removes it on the way in.
32
+ */
33
+ private searchExpandedKeys = new Set<string>();
34
+
35
+ /**
36
+ * Collapse keys the user has explicitly acted on at least once. Growth is
37
+ * unbounded for the life of the conversation — membership only ever gates
38
+ * one decision (whether restoreSearchExpansions() may re-collapse a key),
39
+ * so a stale positive is harmless and pruning would add complexity for no
40
+ * behavioral gain.
41
+ */
42
+ private userTouchedKeys = new Set<string>();
43
+
44
+ /** Record that `collapseKey` was expanded because the user navigated to a
45
+ * search match hidden inside it. No-op for a key the user already
46
+ * touched explicitly, since that ownership always wins. */
47
+ markSearchExpanded(collapseKey: string): void {
48
+ if (!this.userTouchedKeys.has(collapseKey)) this.searchExpandedKeys.add(collapseKey);
49
+ }
50
+
51
+ /** Record an explicit user action on `collapseKey` (toggle/copy/bookmark/
52
+ * save/bulk-expand). Exempts it from restoreSearchExpansions()'s
53
+ * auto-re-collapse for the rest of the session. */
54
+ noteUserTouch(collapseKey: string): void {
55
+ this.userTouchedKeys.add(collapseKey);
56
+ this.searchExpandedKeys.delete(collapseKey);
57
+ }
58
+
59
+ /** Re-collapse every key search auto-expanded and not since user-touched,
60
+ * through `conversationManager`'s public setCollapsed() — called from
61
+ * SearchManager.close() to restore pre-search collapse state. */
62
+ restoreOnto(conversationManager: CollapseSettable): void {
63
+ const keys = [...this.searchExpandedKeys];
64
+ this.searchExpandedKeys.clear();
65
+ for (const key of keys) conversationManager.setCollapsed(key, true);
66
+ }
67
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * conversation-splash-state.ts — when the splash owns the conversation area,
3
+ * and what has to happen the moment it stops.
4
+ *
5
+ * Two pieces of state, kept out of ConversationManager (core/conversation.ts,
6
+ * already at its line-count gate) and out of `suppressSplash`, which is a
7
+ * per-frame posture the panel workspace sets both ways on every render:
8
+ *
9
+ * - `dismissed` — sticky for the run. Owner rule: the splash yields to ANY
10
+ * submission, a chat message or a slash command alike. A slash command
11
+ * whose whole output is a modal (or nothing) still means the session has
12
+ * started, so the splash must not reappear underneath it, and must not
13
+ * come back in the gap before the first reply arrives.
14
+ *
15
+ * - the splash→transcript EDGE — latched when a rebuild that would have
16
+ * drawn the splash draws transcript content instead. The shell consumes
17
+ * it to repaint the whole viewport for that one frame. Without that, the
18
+ * differential renderer only rewrites rows it knows changed, so every row
19
+ * the incoming transcript does not cover keeps showing splash art: rule
20
+ * lines, the hint line, and half-overwritten rows where a short transcript
21
+ * line collides with the tail of a longer splash line.
22
+ */
23
+ export class SplashGateState {
24
+ private dismissedForRun = false;
25
+ private onScreen = false;
26
+ private yielded = false;
27
+
28
+ /** True once the run's first submission has retired the splash. */
29
+ get dismissed(): boolean {
30
+ return this.dismissedForRun;
31
+ }
32
+
33
+ /** Retire the splash for the run. Returns true when this call changed anything. */
34
+ dismiss(): boolean {
35
+ if (this.dismissedForRun) return false;
36
+ this.dismissedForRun = true;
37
+ return true;
38
+ }
39
+
40
+ /** Record that this rebuild drew the splash. */
41
+ enter(): void {
42
+ this.onScreen = true;
43
+ }
44
+
45
+ /** Record that this rebuild drew transcript content; latches the edge. */
46
+ leave(): void {
47
+ if (!this.onScreen) return;
48
+ this.onScreen = false;
49
+ this.yielded = true;
50
+ }
51
+
52
+ /** True exactly once per splash→transcript transition. */
53
+ consumeTransition(): boolean {
54
+ const transition = this.yielded;
55
+ this.yielded = false;
56
+ return transition;
57
+ }
58
+ }
@@ -5,8 +5,7 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
5
5
  import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
6
6
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
8
- // SystemMessageKind imported from runtime directly to avoid cycle:
9
- // conversation.ts → system-message-router.ts → conversation.ts
8
+ // SystemMessageKind imported from runtime directly to avoid cycle: conversation.ts → system-message-router.ts → conversation.ts
10
9
  import type { SystemMessageKind } from '@/runtime/index.ts';
11
10
  import {
12
11
  ConversationManager as SdkConversationManager,
@@ -15,6 +14,9 @@ import {
15
14
  import type { BlockMeta } from './conversation-types.ts';
16
15
  import { MessageLineCache } from './conversation-line-cache.ts';
17
16
  import { UserReceiptIndices } from './conversation-user-receipts.ts';
17
+ import { SplashGateState } from './conversation-splash-state.ts';
18
+ import { resolveTranscriptEventLine } from './conversation-event-navigation.ts';
19
+ import { SearchExpansionTracker } from './conversation-search-expansion.ts';
18
20
  import {
19
21
  addConversationSplashScreen,
20
22
  conversationTextToLines,
@@ -111,6 +113,7 @@ export class ConversationManager extends SdkConversationManager {
111
113
  private _configManager: ConfigManager | null = null;
112
114
  /** Collapse state: stable key (msg_N) -> collapsed (true = collapsed). */
113
115
  private collapseState: Map<string, boolean> = new Map();
116
+ public readonly searchExpansion = new SearchExpansionTracker();
114
117
  /** Block registry: track rendered blocks for copy/apply. */
115
118
  protected blockRegistry: BlockMeta[] = [];
116
119
  /**
@@ -167,6 +170,8 @@ export class ConversationManager extends SdkConversationManager {
167
170
 
168
171
  public suppressSplash: boolean = false;
169
172
  public splashOptions: SplashOptions = {};
173
+ /** Run-sticky dismissal + the splash→transcript edge (conversation-splash-state.ts). */
174
+ private readonly splashGate = new SplashGateState();
170
175
 
171
176
  constructor(
172
177
  getWidth: () => number = () => process.stdout.columns || 80,
@@ -488,10 +493,12 @@ export class ConversationManager extends SdkConversationManager {
488
493
  return true;
489
494
  });
490
495
 
491
- if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
496
+ if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash && !this.splashGate.dismissed) {
492
497
  this.addSplashScreen(width);
498
+ this.splashGate.enter();
493
499
  return;
494
500
  }
501
+ this.splashGate.leave(); // latches the splash→transcript edge for consumeSplashTransition()
495
502
 
496
503
  // The in-progress streaming placeholder (rendered EMPTY above via
497
504
  // renderSnapshot) is left uncached: its content mutates in place per delta and
@@ -643,6 +650,7 @@ export class ConversationManager extends SdkConversationManager {
643
650
  if (!nearest) return -1;
644
651
  const current = this.collapseState.get(nearest.collapseKey) ?? false;
645
652
  this.collapseState.set(nearest.collapseKey, !current);
653
+ this.searchExpansion.noteUserTouch(nearest.collapseKey);
646
654
  this.markDirty();
647
655
  return nearest.blockIndex;
648
656
  }
@@ -704,30 +712,12 @@ export class ConversationManager extends SdkConversationManager {
704
712
 
705
713
  public nextTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
706
714
  this.flushHistory();
707
- const index = this.getTranscriptEventIndex();
708
- const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
709
- if (events.length === 0) return -1;
710
- const lines = events
711
- .map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
712
- .filter((line) => line >= 0)
713
- .sort((a, b) => a - b);
714
- if (lines.length === 0) return -1;
715
- const after = lines.find((line) => line > currentLine);
716
- return after ?? lines[0]!;
715
+ return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'next');
717
716
  }
718
717
 
719
718
  public prevTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
720
719
  this.flushHistory();
721
- const index = this.getTranscriptEventIndex();
722
- const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
723
- if (events.length === 0) return -1;
724
- const lines = events
725
- .map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
726
- .filter((line) => line >= 0)
727
- .sort((a, b) => a - b);
728
- if (lines.length === 0) return -1;
729
- const before = [...lines].reverse().find((line) => line < currentLine);
730
- return before ?? lines[lines.length - 1]!;
720
+ return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'prev');
731
721
  }
732
722
 
733
723
  public setSplashSuppressed(suppressed: boolean): void {
@@ -736,6 +726,16 @@ export class ConversationManager extends SdkConversationManager {
736
726
  this.markDirty();
737
727
  }
738
728
 
729
+ /** Retire the splash for the run — any submission does it (SplashGateState). */
730
+ public dismissSplash(): void {
731
+ if (this.splashGate.dismiss()) this.markDirty();
732
+ }
733
+
734
+ /** True once, on the frame the splash gives way to transcript content. */
735
+ public consumeSplashTransition(): boolean {
736
+ return this.splashGate.consumeTransition();
737
+ }
738
+
739
739
  private addSplashScreen(width: number): void {
740
740
  addConversationSplashScreen(this.renderingContext(), width);
741
741
  }
@@ -9,18 +9,22 @@
9
9
  * narration of their own.
10
10
  *
11
11
  * Division of labour with the failover path: the optimizer-gated failover in
12
- * stream-event-wiring.ts already renders a richer `[Failover] from → to
13
- * (reason)(+cost)` line AND records the transition into the optimizer's
14
- * fallback log. So when a MODEL_CHANGED correlates to a just-recorded fallback
15
- * transition, this chip stays silent (buildRoutingChip returns null) — the
16
- * [Failover] line already narrated that exact change with its real reason.
17
- * Every other change is surfaced here.
12
+ * stream-event-wiring.ts renders a richer `[Failover] from → to
13
+ * (reason)(+cost)` line for the switches it makes itself, and tells this
14
+ * listener so directly (see the self-narrated guard there) rather than relying
15
+ * on this module to infer it. Those changes never reach buildRoutingChip.
18
16
  *
19
- * Reason honesty: for the paths this chip surfaces the routing layer exposes no
20
- * machine-readable reason (a deliberate user switch has none; an SDK-internal
21
- * downgrade does not thread one to the TUI), so the notice states "reason
22
- * unknown" rather than omitting the change — per the honesty rule that a change
23
- * with no explanation is still reported, just marked unknown.
17
+ * Reason honesty, in three tiers:
18
+ * 1. a change the failover path already narrated → not offered here at all;
19
+ * 2. a change that correlates to a transition in the optimizer's fallback
20
+ * log → the log's own reason is quoted, because the reason IS known. The
21
+ * log is shared: the TUI's failover records provider ids ("abacusai"),
22
+ * the SDK's agent-orchestrator fallback records registry keys
23
+ * ("abacusai:route-llm"), and a match on either form counts. Reading only
24
+ * one form is what made a real, explained failover print
25
+ * "(reason unknown)";
26
+ * 3. no correlation → "(reason unknown)", because a change with no
27
+ * explanation is still reported, just honestly marked as unexplained.
24
28
  */
25
29
 
26
30
  /** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
@@ -44,9 +48,22 @@ export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
44
48
  /** The forced conversation prefix so the notice is never gated as noise or routed away. */
45
49
  export const ROUTING_CHIP_PREFIX = '[Routing]';
46
50
 
51
+ /**
52
+ * The provider id of a fallback-log endpoint, which may be written either as a
53
+ * bare provider id or as a `provider:model` registry key depending on which
54
+ * writer recorded it. Everything before the first ':' is the provider in both
55
+ * forms, so one rule reads both.
56
+ */
57
+ function providerOf(endpoint: string): string {
58
+ const separator = endpoint.indexOf(':');
59
+ return separator === -1 ? endpoint : endpoint.slice(0, separator);
60
+ }
61
+
47
62
  /**
48
63
  * Most recent fallback-log reason for a from→to provider transition within the
49
64
  * correlation window, or null. Scans newest-first so the freshest transition wins.
65
+ * Endpoints are compared by provider id, so entries recorded as registry keys
66
+ * correlate exactly like entries recorded as bare provider ids.
50
67
  */
51
68
  export function findRecentFallbackReason(
52
69
  fallbackLog: readonly FallbackTransitionLike[],
@@ -57,17 +74,20 @@ export function findRecentFallbackReason(
57
74
  ): string | null {
58
75
  for (let i = fallbackLog.length - 1; i >= 0; i--) {
59
76
  const t = fallbackLog[i]!;
60
- if (t.from === fromProvider && t.to === toProvider && now - t.ts <= windowMs) return t.reason;
77
+ if (providerOf(t.from) === providerOf(fromProvider)
78
+ && providerOf(t.to) === providerOf(toProvider)
79
+ && now - t.ts <= windowMs) return t.reason;
61
80
  }
62
81
  return null;
63
82
  }
64
83
 
65
84
  /**
66
85
  * The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
67
- * silent. Null only when there is no real change (no/same previous model) or
68
- * when the change was a failover already narrated by its own [Failover] line
69
- * (correlated via the fallback log). Otherwise returns the honest
70
- * `[Routing] model changed: old → new (reason unknown)` notice.
86
+ * silent. Null only when there is no real change (no/same previous model) —
87
+ * changes the failover path narrates itself never reach this function.
88
+ *
89
+ * A change that correlates to a logged fallback transition is reported WITH
90
+ * that transition's reason; anything else is reported as "reason unknown".
71
91
  */
72
92
  export function buildRoutingChip(
73
93
  change: ModelChangedLike,
@@ -77,6 +97,6 @@ export function buildRoutingChip(
77
97
  const prev = change.previous;
78
98
  if (!prev || prev.registryKey === change.registryKey) return null;
79
99
  const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
80
- if (failoverReason !== null) return null; // [Failover] already narrated this transition
81
- return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason unknown)`;
100
+ const reason = failoverReason === null ? 'reason unknown' : `failover: ${failoverReason}`;
101
+ return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (${reason})`;
82
102
  }