@pellux/goodvibes-tui 0.22.0 → 0.23.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.
@@ -1,6 +1,7 @@
1
1
  import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
2
  import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
3
3
  import { formatUserFacingErrorLine } from './format-user-error.ts';
4
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
4
5
 
5
6
  /**
6
7
  * Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
@@ -29,9 +30,28 @@ interface StreamOrchestrator {
29
30
  readonly streamingOutputTokens: number;
30
31
  }
31
32
 
32
- /** Minimal provider surface required for the stream stall watchdog. */
33
+ /** Minimal provider surface required for the stream stall watchdog and failover switching. */
33
34
  interface StreamProviderRegistry {
34
- getCurrentModel(): { readonly provider: string };
35
+ getCurrentModel(): { readonly provider: string; readonly registryKey?: string };
36
+ setCurrentModel(registryKey: string): void;
37
+ }
38
+
39
+ /**
40
+ * Minimal fallback-chain node shape returned by ProviderOptimizer.testFallback().
41
+ * Only the fields consumed by the failover path are declared here.
42
+ */
43
+ interface FailoverChainNode {
44
+ readonly position: number;
45
+ readonly providerId: string;
46
+ readonly modelId: string;
47
+ readonly capable: boolean;
48
+ }
49
+
50
+ /** Minimal ProviderOptimizer surface required by the failover path. */
51
+ interface FailoverOptimizer {
52
+ readonly enabled: boolean;
53
+ testFallback(profile?: Record<string, unknown>): { readonly chain: readonly FailoverChainNode[] };
54
+ recordFallbackTransition(from: string, to: string, reason: string): void;
35
55
  }
36
56
 
37
57
  /** Minimal system-message surface required for user-visible notifications. */
@@ -56,6 +76,32 @@ export interface WireStreamEventMetricsOptions {
56
76
  * so the render closure can read it without a forward-reference issue.
57
77
  */
58
78
  readonly metrics: StreamMetrics;
79
+ /**
80
+ * When provided and enabled, the optimizer is consulted on TURN_ERROR to
81
+ * attempt the next viable provider before surfacing the error to the user.
82
+ * When absent or optimizer.enabled is false, behaviour is identical to the
83
+ * pre-failover baseline: error surfaces immediately via systemMessageRouter.
84
+ */
85
+ readonly providerOptimizer?: FailoverOptimizer;
86
+ /**
87
+ * Callback the caller provides to re-submit the last user turn on a
88
+ * different provider after a successful failover switch. Called only when
89
+ * the optimizer is enabled and a viable next provider exists in the chain.
90
+ */
91
+ readonly retryTurn?: () => void;
92
+ }
93
+
94
+ /** Result of wireStreamEventMetrics. */
95
+ export interface WireStreamEventMetricsResult {
96
+ /** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
97
+ readonly unsubs: ReadonlyArray<() => void>;
98
+ /**
99
+ * Clear the per-turn failover visited-provider set.
100
+ * Call this on every new user submission so the visited set does not bleed
101
+ * across independent turns (the set is also cleared automatically on
102
+ * TURN_COMPLETED, but a new submission may arrive before TURN_COMPLETED fires).
103
+ */
104
+ readonly clearFailoverVisited: () => void;
59
105
  }
60
106
 
61
107
  /**
@@ -64,8 +110,7 @@ export interface WireStreamEventMetricsOptions {
64
110
  * and declares it before render() so both the render closure and the returned
65
111
  * event handlers share the same reference.
66
112
  *
67
- * Returns an array of unsubscribe functions; push them into the parent unsubs
68
- * array so they are cleaned up on exit.
113
+ * Returns an object with unsubscribe functions and a clearFailoverVisited helper.
69
114
  *
70
115
  * Responsibilities:
71
116
  * - Track stream start time, delta count, token speed, and TTFT
@@ -75,8 +120,11 @@ export interface WireStreamEventMetricsOptions {
75
120
  */
76
121
  export function wireStreamEventMetrics(
77
122
  options: WireStreamEventMetricsOptions,
78
- ): ReadonlyArray<() => void> {
79
- const { events, metrics, orchestrator, providerRegistry, systemMessageRouter, render } = options;
123
+ ): WireStreamEventMetricsResult {
124
+ const {
125
+ events, metrics, orchestrator, providerRegistry,
126
+ systemMessageRouter, render, providerOptimizer, retryTurn,
127
+ } = options;
80
128
 
81
129
  const unsubs: Array<() => void> = [];
82
130
 
@@ -103,8 +151,78 @@ export function wireStreamEventMetrics(
103
151
  metrics.tokenSpeed = elapsed > 0 ? tokenCount / elapsed : 0;
104
152
  }));
105
153
 
154
+ // Per-turn visited-provider set: tracks providers already attempted this turn
155
+ // so failover cannot ping-pong between two mutually-failing providers.
156
+ // True invariant: at most one retry per provider per turn; exhaustion fires
157
+ // after the chain is consumed.
158
+ // Cleared on TURN_COMPLETED (see handler below) and on new user submission
159
+ // (caller clears via clearFailoverVisited(), wired in main.ts).
160
+ const failoverVisited = new Set<string>();
161
+
162
+ unsubs.push(events.turns.on('TURN_COMPLETED', () => {
163
+ failoverVisited.clear();
164
+ }));
165
+
106
166
  unsubs.push(events.turns.on('TURN_ERROR', (event) => {
107
167
  const errVal: string = event.error;
168
+
169
+ // --- Optimizer-gated failover path ---
170
+ // When the optimizer is present and enabled, attempt to advance to the next
171
+ // viable provider in the fallback chain before surfacing the error. When
172
+ // the optimizer is absent or disabled, behaviour is identical to baseline:
173
+ // error surfaces immediately.
174
+ if (providerOptimizer?.enabled && retryTurn) {
175
+ const fromProvider = providerRegistry.getCurrentModel().provider;
176
+ // Mark the failing provider as visited so it will never be selected again
177
+ // in this turn, even if a second TURN_ERROR arrives (e.g. ping-pong).
178
+ failoverVisited.add(fromProvider);
179
+ const result = providerOptimizer.testFallback({});
180
+ // Find the first capable node that is NOT already visited this turn and
181
+ // is NOT synthetic. Synthetic nodes are skipped permanently by design:
182
+ // a synthetic model is itself a fallback ladder over real backends, so
183
+ // failing over INTO one after a real backend already failed is unsound
184
+ // double-indirection (it can route straight back to the failed provider).
185
+ const next = result.chain.find(
186
+ (node) =>
187
+ node.capable &&
188
+ !failoverVisited.has(node.providerId) &&
189
+ node.providerId !== 'synthetic',
190
+ );
191
+
192
+ if (next) {
193
+ const toRegistryKey = `${next.providerId}:${next.modelId}`;
194
+ const errorClass = formatUserFacingErrorLine(errVal);
195
+ try {
196
+ providerRegistry.setCurrentModel(toRegistryKey);
197
+ } catch (switchErr) {
198
+ // Switch failed — fall through to honest error display.
199
+ logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
200
+ systemMessageRouter.high(`[Error] ${errorClass}`);
201
+ render();
202
+ return;
203
+ }
204
+ // Record the selected provider as visited before the retry fires so
205
+ // a subsequent TURN_ERROR from that provider also skips it.
206
+ failoverVisited.add(next.providerId);
207
+ providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
208
+ systemMessageRouter.high(
209
+ `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})`,
210
+ );
211
+ render();
212
+ // Re-submit the last user turn on the new provider.
213
+ retryTurn();
214
+ return;
215
+ }
216
+
217
+ // Chain exhausted — all capable candidates have been visited or none exist.
218
+ systemMessageRouter.high(
219
+ `[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
220
+ );
221
+ render();
222
+ return;
223
+ }
224
+
225
+ // Baseline: optimizer disabled or not wired — surface error immediately.
108
226
  const formatted = formatUserFacingErrorLine(errVal);
109
227
  systemMessageRouter.high(`[Error] ${formatted}`);
110
228
  render();
@@ -140,5 +258,5 @@ export function wireStreamEventMetrics(
140
258
  metrics.activeToolName = undefined;
141
259
  }));
142
260
 
143
- return unsubs;
261
+ return { unsubs, clearFailoverVisited: () => failoverVisited.clear() };
144
262
  }
@@ -0,0 +1,139 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { requireIntegrationHelpers } from './runtime-services.ts';
3
+
4
+ export function registerChannelRuntimeCommands(registry: CommandRegistry): void {
5
+ registry.register({
6
+ name: 'channel',
7
+ aliases: [],
8
+ description: 'Inspect channel routes, delivery strategies, and ingress policies',
9
+ usage: '[status|routes|delivery|policy] [--json]',
10
+ argsHint: 'status | routes | delivery | policy',
11
+ handler(args, ctx) {
12
+ const sub = args[0];
13
+ const asJson = args.includes('--json');
14
+
15
+ if (!sub || sub === 'open' || sub === 'panel') {
16
+ if (ctx.showPanel) ctx.showPanel('routes');
17
+ return;
18
+ }
19
+
20
+ const helpers = requireIntegrationHelpers(ctx);
21
+
22
+ if (sub === 'status') {
23
+ const review = helpers.buildReview();
24
+ if (asJson) {
25
+ ctx.print(JSON.stringify(review, null, 2));
26
+ return;
27
+ }
28
+ const lines: string[] = [
29
+ 'Channel Status',
30
+ ` routes: ${review.routes.length}`,
31
+ ` api families: ${review.apiFamilies.join(', ') || '(none)'}`,
32
+ ` sessions: ${review.sessions}`,
33
+ ` tasks: ${review.tasks}`,
34
+ ` pending approvals: ${review.pendingApprovals}`,
35
+ ` remote contracts: ${review.remoteContracts}`,
36
+ '',
37
+ `Active route families: ${review.routes.join(', ') || '(none)'}`,
38
+ '',
39
+ 'Use /channel routes for delivery binding details.',
40
+ 'Use /channel delivery for outbound delivery snapshot.',
41
+ 'Use /channel policy for ingress policy snapshot.',
42
+ ];
43
+ ctx.print(lines.join('\n'));
44
+ return;
45
+ }
46
+
47
+ if (sub === 'routes') {
48
+ const snapshot = helpers.getRouteSnapshot();
49
+ if (asJson) {
50
+ ctx.print(JSON.stringify(snapshot, null, 2));
51
+ return;
52
+ }
53
+ const entries = Object.entries(snapshot);
54
+ if (entries.length === 0) {
55
+ ctx.print('No route bindings active.\n\nRoutes become active when channel surfaces (slack, discord, ntfy, webhook, etc.) are configured and the daemon is running.');
56
+ return;
57
+ }
58
+ const lines: string[] = ['Channel Routes'];
59
+ for (const [key, value] of entries) {
60
+ lines.push(` ${String(key).padEnd(28)} ${JSON.stringify(value)}`);
61
+ }
62
+ lines.push('');
63
+ lines.push('Route bindings reflect active daemon surface registrations.');
64
+ ctx.print(lines.join('\n'));
65
+ return;
66
+ }
67
+
68
+ if (sub === 'delivery') {
69
+ const snapshot = helpers.getDeliverySnapshot();
70
+ if (asJson) {
71
+ ctx.print(JSON.stringify(snapshot, null, 2));
72
+ return;
73
+ }
74
+ const entries = Object.entries(snapshot);
75
+ if (entries.length === 0) {
76
+ ctx.print('No delivery snapshot available.\n\nDelivery state is populated when the daemon handles outbound channel messages.');
77
+ return;
78
+ }
79
+ const lines: string[] = ['Channel Delivery Snapshot'];
80
+ for (const [key, value] of entries) {
81
+ lines.push(` ${String(key).padEnd(28)} ${JSON.stringify(value)}`);
82
+ }
83
+ ctx.print(lines.join('\n'));
84
+ return;
85
+ }
86
+
87
+ if (sub === 'policy') {
88
+ const configManager = ctx.platform.configManager;
89
+ // Channel policy is persisted by ChannelPolicyManager in
90
+ // .goodvibes/tui/channels/policies.json — surface via configManager
91
+ // category (runtime-accessible without a daemon round-trip).
92
+ const surfaces = [
93
+ 'slack', 'discord', 'ntfy', 'webhook', 'homeassistant',
94
+ 'telegram', 'google-chat', 'signal', 'whatsapp',
95
+ 'imessage', 'msteams', 'bluebubbles', 'mattermost', 'matrix',
96
+ ];
97
+ const lines: string[] = ['Channel Ingress Policies'];
98
+ let found = false;
99
+ for (const surface of surfaces) {
100
+ const key = `surfaces.${surface}.enabled` as Parameters<typeof configManager.get>[0];
101
+ const enabled = configManager.get(key);
102
+ if (enabled !== undefined && enabled !== null) {
103
+ found = true;
104
+ lines.push(` ${surface.padEnd(20)} enabled=${String(enabled)}`);
105
+ }
106
+ }
107
+ if (!found) {
108
+ lines.push(' No channel surfaces configured.');
109
+ lines.push('');
110
+ lines.push(' Configure surfaces via /onboarding or Settings > Surfaces.');
111
+ lines.push(' Fine-grained ingress policies (allowedCommands, requireMention, groupPolicies)');
112
+ lines.push(' are managed by ChannelPolicyManager in .goodvibes/tui/channels/policies.json.');
113
+ } else {
114
+ lines.push('');
115
+ lines.push(' Fine-grained ingress policies (allowedCommands, requireMention, groupPolicies)');
116
+ lines.push(' are managed by ChannelPolicyManager in .goodvibes/tui/channels/policies.json.');
117
+ }
118
+ if (asJson) {
119
+ ctx.print(JSON.stringify({ surfaces: Object.fromEntries(surfaces.map((s) => [s, configManager.get(`surfaces.${s}.enabled` as Parameters<typeof configManager.get>[0])])) }, null, 2));
120
+ return;
121
+ }
122
+ ctx.print(lines.join('\n'));
123
+ return;
124
+ }
125
+
126
+ ctx.print(
127
+ 'Usage: /channel <subcommand>\n'
128
+ + ' (no args) — open the Routes panel\n'
129
+ + ' status — channel overview: routes, sessions, tasks, pending approvals\n'
130
+ + ' routes — active route binding snapshot\n'
131
+ + ' delivery — outbound delivery snapshot\n'
132
+ + ' policy — configured channel surfaces and ingress policy location\n'
133
+ + '\n'
134
+ + 'Options:\n'
135
+ + ' --json Output raw JSON for scripting'
136
+ );
137
+ },
138
+ });
139
+ }
@@ -7,6 +7,8 @@ import type {
7
7
  CommandSessionServices,
8
8
  CommandWorkspaceServices,
9
9
  } from '../command-registry.ts';
10
+ import { getLastCompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
11
+ import type { CompactionContext, CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
10
12
  import type { UiReadModels } from '../../runtime/ui-read-models.ts';
11
13
  import type { ShellPathService } from '@/runtime/index.ts';
12
14
  import type { EcosystemCatalogPathOptions } from '@/runtime/index.ts';
@@ -236,13 +238,40 @@ export function requireProviderApi(context: CommandContext): ProviderApi {
236
238
  return requireContextValue(context.clients?.providerApi, 'clients.providerApi');
237
239
  }
238
240
 
239
- export async function compactConversation(context: CommandContext): Promise<void> {
241
+ /**
242
+ * Compact the conversation and return the CompactionEvent recorded by the SDK,
243
+ * or null if no event was recorded (e.g. compaction was skipped or produced no
244
+ * change).
245
+ */
246
+ export async function compactConversation(context: CommandContext): Promise<CompactionEvent | null> {
247
+ const eventBefore = getLastCompactionEvent();
248
+ const sessionMemories = context.session.sessionMemoryStore?.list() ?? [];
249
+ const compactionCtx: CompactionContext = {
250
+ messages: context.session.conversationManager.getMessagesForLLM(),
251
+ sessionMemories,
252
+ agents: [],
253
+ wrfcChains: [],
254
+ activePlan: null,
255
+ lineageEntries: [],
256
+ compactionCount: 0,
257
+ contextWindow: 0,
258
+ trigger: 'manual',
259
+ extractionModelId: context.session.runtime.model,
260
+ extractionProvider: context.session.runtime.provider,
261
+ };
240
262
  await context.session.conversationManager.compact(
241
263
  context.provider.providerRegistry,
242
264
  context.session.runtime.model,
243
265
  'manual',
244
266
  context.session.runtime.provider,
267
+ compactionCtx,
245
268
  );
269
+ const eventAfter = getLastCompactionEvent();
270
+ // Return the new event only if it differs from the one recorded before the call.
271
+ if (eventAfter !== null && eventAfter !== eventBefore) {
272
+ return eventAfter;
273
+ }
274
+ return null;
246
275
  }
247
276
 
248
277
  export function requireKnowledgeApi(context: CommandContext): KnowledgeApi {
@@ -17,7 +17,7 @@ import {
17
17
  resolveGithubToken,
18
18
  } from '../../export/gist-uploader.ts';
19
19
  import { copyToClipboard } from '../../utils/clipboard.ts';
20
- import { openBrowser } from '../../cli/management.ts';
20
+ import { openBrowser } from '../../utils/browser.ts';
21
21
 
22
22
  export function registerShareRuntimeCommands(registry: CommandRegistry): void {
23
23
  registry.register({
@@ -3,7 +3,9 @@ import type { SelectionItem } from '../selection-modal.ts';
3
3
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
4
  import { REASONING_BUDGET_MAP } from '@pellux/goodvibes-sdk/platform/providers';
5
5
  import { executeWriteQuit } from './quit-shared.ts';
6
- import { compactConversation, requireKeybindingsManager, requireProviderApi } from './runtime-services.ts';
6
+ import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
7
+ import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
8
+ import { buildCompactionHistoryText } from '../../renderer/compaction-history-modal.ts';
7
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
11
 
@@ -204,9 +206,57 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
204
206
  aliases: [],
205
207
  description: 'Summarize conversation to free context window',
206
208
  async handler(_args, ctx) {
207
- ctx.print('Compacting conversation...');
208
- await compactConversation(ctx);
209
- ctx.print('Conversation compacted.');
209
+ const messages = ctx.session.conversationManager.getMessagesForLLM();
210
+ // contextWindow is not on CommandContext; preview shows message/token counts
211
+ // without the capacity-% clause (still honest; no fabricated value).
212
+ const contextWindow = 0;
213
+ const memStore = ctx.session.sessionMemoryStore;
214
+ const pinnedMemoryCount = memStore ? memStore.list().length : 0;
215
+ // Pre-compact preview: honest estimate, clearly labelled.
216
+ const preview = buildCompactionPreview({ messages, contextWindow, pinnedMemoryCount, trigger: 'manual' });
217
+ ctx.print(preview);
218
+ const event = await compactConversation(ctx);
219
+ if (event) {
220
+ // Post-compact notice: uses real CompactionEvent figures.
221
+ ctx.print(buildCompactionAfterNotice({ event, pinnedMemoryCount }));
222
+ } else {
223
+ ctx.print('[Context] Compact complete.');
224
+ }
225
+ ctx.renderRequest();
226
+ },
227
+ });
228
+
229
+ registry.register({
230
+ name: 'compact-history',
231
+ aliases: ['compaction-history'],
232
+ description: 'Show compaction history for this session',
233
+ handler(_args, ctx) {
234
+ ctx.print(buildCompactionHistoryText());
235
+ ctx.renderRequest();
236
+ },
237
+ });
238
+
239
+ registry.register({
240
+ name: 'keep',
241
+ aliases: [],
242
+ description: 'Pin text to session memory (survives compaction)',
243
+ usage: '<text>',
244
+ argsHint: '<text to preserve>',
245
+ handler(args, ctx) {
246
+ const text = args.join(' ').trim();
247
+ if (!text) {
248
+ ctx.print(buildPinUsageText());
249
+ ctx.renderRequest();
250
+ return;
251
+ }
252
+ const memStore = requireSessionMemoryStore(ctx);
253
+ const id = memStore.add(text);
254
+ if (!id) {
255
+ ctx.print('[Pin] Nothing pinned — text was blank.');
256
+ } else {
257
+ const count = memStore.list().length;
258
+ ctx.print(buildPinSuccessText(id, text, count));
259
+ }
210
260
  ctx.renderRequest();
211
261
  },
212
262
  });
@@ -18,6 +18,7 @@ import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
18
18
  import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
19
19
  import { registerReplayRuntimeCommands } from './commands/replay-runtime.ts';
20
20
  import { registerShareRuntimeCommands } from './commands/share-runtime.ts';
21
+ import { registerChannelRuntimeCommands } from './commands/channel-runtime.ts';
21
22
  import { registerLocalSetupCommands } from './commands/local-setup.ts';
22
23
  import { registerProductRuntimeCommands } from './commands/product-runtime.ts';
23
24
  import { registerPlatformRuntimeCommands } from './commands/platform-runtime.ts';
@@ -70,6 +71,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
70
71
  registerNotifyRuntimeCommands(registry);
71
72
  registerReplayRuntimeCommands(registry);
72
73
  registerShareRuntimeCommands(registry);
74
+ registerChannelRuntimeCommands(registry);
73
75
  registerLocalSetupCommands(registry);
74
76
  registerProductRuntimeCommands(registry);
75
77
  registerPlatformRuntimeCommands(registry);
package/src/main.ts CHANGED
@@ -54,22 +54,16 @@ import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/ter
54
54
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
55
55
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
56
56
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
57
- import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
57
+ import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
58
58
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
59
59
  import { buildContextStatusHint } from './renderer/context-status-hint.ts';
60
60
  import { evaluateSessionMaintenance } from './panels/session-maintenance.ts';
61
61
 
62
- const ALT_SCREEN_ENTER = '\x1b[?1049h';
63
- const ALT_SCREEN_EXIT = '\x1b[?1049l';
64
- const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
65
- const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
66
- const CURSOR_HIDE = '\x1b[?25l';
67
- const CURSOR_SHOW = '\x1b[?25h';
68
- const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
69
- const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u';
70
- const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
71
- const PASTE_ENABLE = '\x1b[?2004h';
72
- const PASTE_DISABLE = '\x1b[?2004l';
62
+ const ALT_SCREEN_ENTER = '\x1b[?1049h'; const ALT_SCREEN_EXIT = '\x1b[?1049l';
63
+ const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'; const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
64
+ const CURSOR_HIDE = '\x1b[?25l'; const CURSOR_SHOW = '\x1b[?25h'; const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
65
+ const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u'; const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
66
+ const PASTE_ENABLE = '\x1b[?2004h'; const PASTE_DISABLE = '\x1b[?2004l';
73
67
 
74
68
  async function main() {
75
69
  const stdout = process.stdout;
@@ -315,10 +309,11 @@ async function main() {
315
309
  }
316
310
  if (processedText || content) {
317
311
  void (async () => {
318
- let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
319
- if (options.spokenOutput && processedText) {
320
- spokenTurns.submitNextTurn(processedText);
321
- }
312
+ const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
313
+ if (options.spokenOutput && processedText) { spokenTurns.submitNextTurn(processedText); }
314
+ // Snapshot pre-submission state for failover retryTurn; also clears visited set.
315
+ retryCtx = { count: conversation.getMessageCount(), text: processedText, content, opts: inputOptions };
316
+ streamResult.clearFailoverVisited();
322
317
  orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
323
318
  logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
324
319
  });
@@ -689,7 +684,6 @@ async function main() {
689
684
  render,
690
685
  });
691
686
 
692
- // --- Turn-completed / git-refresh event wiring ---
693
687
  const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
694
688
  events: uiServices.events,
695
689
  conversation,
@@ -709,16 +703,22 @@ async function main() {
709
703
  });
710
704
  unsubs.push(...turnUnsubs);
711
705
 
712
- // --- Stream metrics + tool-timer event wiring ---
713
- const streamUnsubs = wireStreamEventMetrics({
714
- events: uiServices.events,
715
- orchestrator,
716
- providerRegistry,
717
- systemMessageRouter,
718
- render,
719
- metrics: streamMetrics,
706
+ // Stable turn context for failover retry set in submitInput, read by retryTurn.
707
+ let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
708
+ const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
709
+ events: uiServices.events, orchestrator, providerRegistry,
710
+ systemMessageRouter, render, metrics: streamMetrics,
711
+ providerOptimizer: ctx.services.providerOptimizer,
712
+ retryTurn: () => {
713
+ if (!retryCtx) return;
714
+ const { count, text, content: rContent, opts: rOpts } = retryCtx;
715
+ // Roll back to pre-submission count (strips error system messages), then
716
+ // re-submit. SDK gap — no retry-in-place; see HANDOFF item (Issue 2).
717
+ conversation.removeMessagesAfter(count);
718
+ orchestrator.handleUserInput(text, rContent, rOpts).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
719
+ },
720
720
  });
721
- unsubs.push(...streamUnsubs);
721
+ unsubs.push(...streamResult.unsubs);
722
722
 
723
723
  // --- Terminal setup ---
724
724
  stdin.setRawMode(true);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Compaction history text builder.
3
+ *
4
+ * Renders a read-only list of past compaction events sourced from the SDK's
5
+ * module-level compaction event log (`getCompactionEvents()`).
6
+ *
7
+ * The SDK records CompactionEvent data (timestamps, token counts,
8
+ * trigger, message counts) but does not expose a snapshot restore API.
9
+ * Restore is list-only; users can view what compactions ran but cannot roll back.
10
+ */
11
+
12
+ import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
13
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
14
+
15
+ // ─── formatCompactionEvent ────────────────────────────────────────────────────
16
+
17
+ function formatCompactionEvent(ev: CompactionEvent, n: number): string {
18
+ const date = new Date(ev.timestamp);
19
+ const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
+ const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
21
+ const savingsPct = ev.tokensBeforeEstimate > 0
22
+ ? Math.round((savings / ev.tokensBeforeEstimate) * 100)
23
+ : 0;
24
+ const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
25
+ return (
26
+ `#${n} ${timeStr} [${trigger}] ` +
27
+ `${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
28
+ `~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
29
+ `saved ${savingsPct}%`
30
+ );
31
+ }
32
+
33
+ function fmtN(n: number): string {
34
+ return n.toLocaleString();
35
+ }
36
+
37
+ /**
38
+ * Build a plain-text compaction history summary suitable for ctx.print().
39
+ * Useful as the output of /compact-history when not in overlay mode.
40
+ */
41
+ export function buildCompactionHistoryText(): string {
42
+ const events = getCompactionEvents();
43
+ if (events.length === 0) {
44
+ return '[Context] No compactions recorded this session. (Restore is not available — the SDK does not yet expose a snapshot restore API.)';
45
+ }
46
+ const lines: string[] = [
47
+ `[Context] Compaction history (${events.length} total, most recent first):`,
48
+ ];
49
+ const ordered = [...events].reverse();
50
+ for (let i = 0; i < ordered.length; i++) {
51
+ lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
52
+ }
53
+ lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
54
+ return lines.join('\n');
55
+ }