@pellux/goodvibes-tui 0.23.0 → 0.24.1
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.
- package/CHANGELOG.md +34 -0
- package/README.md +20 -12
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- package/src/cli/management.ts +80 -10
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +77 -3
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/control-room-runtime.ts +0 -2
- package/src/input/commands/diff-runtime.ts +1 -1
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +23 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-runtime.ts +1 -2
- package/src/input/commands/memory-product-runtime.ts +2 -2
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/onboarding-runtime.ts +0 -1
- package/src/input/commands/policy.ts +1 -1
- package/src/input/commands/profile-sync-runtime.ts +4 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +0 -1
- package/src/input/commands/session-content.ts +2 -2
- package/src/input/commands/session-workflow.ts +32 -2
- package/src/input/commands/session.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +9 -9
- package/src/input/commands/shell-core.ts +2 -2
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/feed-context-factory.ts +6 -0
- package/src/input/handler-feed-routes.ts +19 -1
- package/src/input/handler-feed.ts +11 -0
- package/src/input/handler-prompt-buffer.ts +28 -0
- package/src/input/handler-shortcuts.ts +88 -2
- package/src/input/handler-ui-state.ts +2 -2
- package/src/input/handler.ts +39 -3
- package/src/input/keybindings.ts +33 -3
- package/src/input/kill-ring.ts +134 -0
- package/src/input/model-picker.ts +18 -1
- package/src/input/search.ts +18 -6
- package/src/input/settings-modal-activation.ts +134 -0
- package/src/input/settings-modal-adjustment.ts +124 -0
- package/src/input/settings-modal-data.ts +53 -0
- package/src/input/settings-modal.ts +48 -145
- package/src/main.ts +33 -33
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/provider-health-domains.ts +3 -3
- package/src/panels/provider-health-panel.ts +13 -9
- package/src/panels/provider-health-tracker.ts +7 -4
- package/src/panels/settings-sync-panel.ts +3 -3
- package/src/panels/work-plan-panel.ts +2 -2
- package/src/renderer/diff-view.ts +2 -2
- package/src/renderer/help-overlay.ts +1 -0
- package/src/renderer/model-picker-overlay.ts +23 -11
- package/src/renderer/progress.ts +3 -3
- package/src/renderer/search-overlay.ts +8 -5
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-hook-bridge.ts +18 -0
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/shell/blocking-input.ts +32 -0
- package/src/shell/recovery-input-helpers.ts +71 -0
- package/src/utils/terminal-width.ts +10 -3
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
100
100
|
"@ast-grep/napi": "^0.42.0",
|
|
101
101
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
102
|
-
"@pellux/goodvibes-sdk": "0.33.
|
|
102
|
+
"@pellux/goodvibes-sdk": "0.33.38",
|
|
103
103
|
"bash-language-server": "^5.6.0",
|
|
104
104
|
"fuse.js": "^7.1.0",
|
|
105
105
|
"graphql": "^16.13.2",
|
package/src/cli/management.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatProviderModel, getModelIdFromProviderModel } from '../config/prov
|
|
|
6
6
|
import { SecretsManager } from '../config/secrets.ts';
|
|
7
7
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
8
8
|
import { listProviderRuntimeSnapshots } from '@pellux/goodvibes-sdk/platform/providers';
|
|
9
|
+
import type { CanonicalModel } from '@pellux/goodvibes-sdk/platform/providers';
|
|
9
10
|
import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, resolveSecretRef } from '@pellux/goodvibes-sdk/platform/config';
|
|
10
11
|
import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
|
|
11
12
|
import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
|
|
@@ -173,6 +174,40 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
|
|
|
173
174
|
});
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Build the synthetic chain entry list for `goodvibes models chain` output.
|
|
179
|
+
* Pure transformation: accepts canonical model data and an optional lowercase filter key,
|
|
180
|
+
* returns a serializable array suitable for both JSON and text formatting.
|
|
181
|
+
*
|
|
182
|
+
* @internal exported for unit testing
|
|
183
|
+
*/
|
|
184
|
+
export function buildSyntheticChainEntries(
|
|
185
|
+
canonicalModels: readonly CanonicalModel[],
|
|
186
|
+
filterKey?: string,
|
|
187
|
+
): Array<{
|
|
188
|
+
id: string;
|
|
189
|
+
tier: string;
|
|
190
|
+
backendCount: number;
|
|
191
|
+
keyedBackendCount: number;
|
|
192
|
+
backends: Array<{ position: number; provider: string; model: string; registryKey: string }>;
|
|
193
|
+
}> {
|
|
194
|
+
const filtered = filterKey
|
|
195
|
+
? canonicalModels.filter((m) => m.id.toLowerCase().includes(filterKey))
|
|
196
|
+
: canonicalModels;
|
|
197
|
+
return filtered.map((m) => ({
|
|
198
|
+
id: m.id,
|
|
199
|
+
tier: m.tier,
|
|
200
|
+
backendCount: m.backendCount,
|
|
201
|
+
keyedBackendCount: m.keyedBackendCount,
|
|
202
|
+
backends: m.backends.map((b, idx) => ({
|
|
203
|
+
position: idx,
|
|
204
|
+
provider: b.providerName,
|
|
205
|
+
model: b.modelId,
|
|
206
|
+
registryKey: b.registryKey ?? `${b.providerName}:${b.modelId}`,
|
|
207
|
+
})),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
176
211
|
async function renderModels(runtime: CliCommandRuntime): Promise<string> {
|
|
177
212
|
return await withRuntimeServices(runtime, async (services) => {
|
|
178
213
|
const [subOrFilter, ...rest] = runtime.cli.commandArgs;
|
|
@@ -246,23 +281,58 @@ async function renderModels(runtime: CliCommandRuntime): Promise<string> {
|
|
|
246
281
|
...recent.map((model) => ` ${model}`),
|
|
247
282
|
].join('\n'));
|
|
248
283
|
}
|
|
284
|
+
if (subOrFilter === 'chain' || subOrFilter === 'chains') {
|
|
285
|
+
// List synthetic model fallback ladders — backend composition for each synthetic model.
|
|
286
|
+
const canonicalModels = services.providerRegistry.getSyntheticCanonicalModels();
|
|
287
|
+
if (canonicalModels.length === 0) {
|
|
288
|
+
return formatJsonOrText(runtime.cli)([], 'No synthetic models found in the current catalog.');
|
|
289
|
+
}
|
|
290
|
+
const filterKey = rest[0]?.toLowerCase();
|
|
291
|
+
const filtered = filterKey
|
|
292
|
+
? canonicalModels.filter((m) => m.id.toLowerCase().includes(filterKey))
|
|
293
|
+
: canonicalModels;
|
|
294
|
+
const value = buildSyntheticChainEntries(filtered);
|
|
295
|
+
return formatJsonOrText(runtime.cli)(value, [
|
|
296
|
+
`GoodVibes synthetic model chains${filterKey ? ` (${filterKey})` : ''}`,
|
|
297
|
+
...value.flatMap((m) => [
|
|
298
|
+
` ${m.id} [${m.tier}] ${m.keyedBackendCount}/${m.backendCount} backends configured`,
|
|
299
|
+
...m.backends.map((b) => ` ${b.position}. ${b.provider}/${b.model}`),
|
|
300
|
+
]),
|
|
301
|
+
].join('\n'));
|
|
302
|
+
}
|
|
249
303
|
const filter = subOrFilter === 'list' ? rest[0]?.toLowerCase() : subOrFilter?.toLowerCase();
|
|
250
304
|
const models = services.providerRegistry
|
|
251
305
|
.getSelectableModels()
|
|
252
306
|
.filter((model) => !filter || model.provider.toLowerCase() === filter || model.registryKey.toLowerCase().includes(filter))
|
|
253
307
|
.slice(0, 200);
|
|
254
|
-
const value = models.map((model) =>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
const value = models.map((model) => {
|
|
309
|
+
const synthInfo = model.provider === 'synthetic'
|
|
310
|
+
? services.providerRegistry.getSyntheticModelInfoFromCatalog(model.id)
|
|
311
|
+
: null;
|
|
312
|
+
return {
|
|
313
|
+
registryKey: model.registryKey,
|
|
314
|
+
provider: model.provider,
|
|
315
|
+
...classifyModelProvider(model.provider),
|
|
316
|
+
id: model.id,
|
|
317
|
+
displayName: model.displayName,
|
|
318
|
+
contextWindow: services.providerRegistry.getContextWindowForModel(model),
|
|
319
|
+
current: model.registryKey === current,
|
|
320
|
+
...(synthInfo !== null ? {
|
|
321
|
+
isSynthetic: true,
|
|
322
|
+
syntheticTier: synthInfo.tier,
|
|
323
|
+
syntheticBackends: synthInfo.backendCount,
|
|
324
|
+
syntheticConfiguredBackends: synthInfo.keyedBackendCount,
|
|
325
|
+
} : {}),
|
|
326
|
+
};
|
|
327
|
+
});
|
|
263
328
|
return formatJsonOrText(runtime.cli)(value, [
|
|
264
329
|
`GoodVibes models${filter ? ` (${filter})` : ''}`,
|
|
265
|
-
...value.map((model) =>
|
|
330
|
+
...value.map((model) => {
|
|
331
|
+
const synthLabel = model.isSynthetic
|
|
332
|
+
? ` [synthetic ${model.syntheticConfiguredBackends}/${model.syntheticBackends}p]`
|
|
333
|
+
: '';
|
|
334
|
+
return ` ${model.current ? '*' : ' '} ${model.registryKey.padEnd(42)} setup=${model.setupClass} ctx=${model.contextWindow.toLocaleString()}${synthLabel} ${model.displayName}`;
|
|
335
|
+
}),
|
|
266
336
|
].join('\n'));
|
|
267
337
|
});
|
|
268
338
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* long-task-notifier — fires push notifications when a turn or agent task
|
|
3
|
+
* completes after running longer than the configured threshold.
|
|
4
|
+
*
|
|
5
|
+
* PRIVACY GUARANTEE: Notification text must never include conversation content
|
|
6
|
+
* (user messages, assistant replies, tool outputs). Only metadata is included:
|
|
7
|
+
* task kind, elapsed time, ok/fail status, and session id. This module enforces
|
|
8
|
+
* that constraint by construction — it receives no conversation object and
|
|
9
|
+
* builds all message text from structural metadata only.
|
|
10
|
+
*
|
|
11
|
+
* Delivery targets (in preference order):
|
|
12
|
+
* 1. Desktop notification (linux notify-send / mac osascript) via SDK
|
|
13
|
+
* notifyCompletion — detected and dispatched by the SDK; silently
|
|
14
|
+
* no-ops when the platform does not support it.
|
|
15
|
+
* 2. Configured outbound webhook channel (ntfy topic / webhook URL) via
|
|
16
|
+
* WebhookNotifier.send() — only fires when the user has URLs configured.
|
|
17
|
+
*
|
|
18
|
+
* When neither target is available the function is an honest no-op (debug log
|
|
19
|
+
* only; no user-facing error spam).
|
|
20
|
+
*
|
|
21
|
+
* Focus tracking: terminal focus state is not tracked anywhere in the TUI.
|
|
22
|
+
* Notifications therefore fire regardless of whether the terminal window is
|
|
23
|
+
* focused. A future implementation may suppress notifications when the TUI is
|
|
24
|
+
* in the foreground by reading a focus-state ref. // seam: wire focus ref here.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
29
|
+
import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
|
|
30
|
+
|
|
31
|
+
/** Default threshold in seconds. Turns shorter than this do not notify. */
|
|
32
|
+
export const NOTIFY_AFTER_SECONDS_DEFAULT = 60;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sentinel value for the off-state. When behavior.notifyAfterSeconds is 0,
|
|
36
|
+
* push notifications are disabled (same convention as other numeric-off keys
|
|
37
|
+
* in the config schema).
|
|
38
|
+
*/
|
|
39
|
+
export const NOTIFY_AFTER_SECONDS_OFF = 0;
|
|
40
|
+
|
|
41
|
+
/** Accepted task kinds for notification messages. */
|
|
42
|
+
export type LongTaskKind = 'turn' | 'agent';
|
|
43
|
+
|
|
44
|
+
/** Completion status for notification messages. */
|
|
45
|
+
export type LongTaskStatus = 'ok' | 'fail';
|
|
46
|
+
|
|
47
|
+
export interface MaybeNotifyLongTaskOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Elapsed milliseconds for the turn or agent task.
|
|
50
|
+
* Must not include any conversation content.
|
|
51
|
+
*/
|
|
52
|
+
readonly elapsedMs: number;
|
|
53
|
+
|
|
54
|
+
/** Whether the task completed successfully or failed. */
|
|
55
|
+
readonly status: LongTaskStatus;
|
|
56
|
+
|
|
57
|
+
/** Task kind label for the notification body. */
|
|
58
|
+
readonly kind: LongTaskKind;
|
|
59
|
+
|
|
60
|
+
/** Session id for correlation. Must not be a PII value. */
|
|
61
|
+
readonly sessionId: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Threshold in seconds from config (behavior.notifyAfterSeconds).
|
|
65
|
+
* 0 means off; notifications are suppressed entirely.
|
|
66
|
+
* Should be the raw config value; this function normalises it.
|
|
67
|
+
*/
|
|
68
|
+
readonly thresholdSeconds: number;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Outbound webhook notifier. When provided and the user has URLs
|
|
72
|
+
* configured, the notification is also sent to all configured endpoints
|
|
73
|
+
* (e.g. ntfy.sh topics). Optional — absent means outbound delivery is
|
|
74
|
+
* skipped silently.
|
|
75
|
+
*/
|
|
76
|
+
readonly webhookNotifier?: WebhookNotifier | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fires push notifications for a completed long task if the elapsed time
|
|
81
|
+
* exceeds the configured threshold.
|
|
82
|
+
*
|
|
83
|
+
* Returns true when at least one delivery was attempted, false when the
|
|
84
|
+
* call was a no-op (threshold not reached, or off-state).
|
|
85
|
+
*
|
|
86
|
+
* PRIVACY: builds message text from structural metadata only (kind, elapsed,
|
|
87
|
+
* status, sessionId). Never includes conversation content.
|
|
88
|
+
*/
|
|
89
|
+
export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
|
|
90
|
+
const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier } = opts;
|
|
91
|
+
|
|
92
|
+
// Off-state: 0 disables notifications entirely.
|
|
93
|
+
if (thresholdSeconds === NOTIFY_AFTER_SECONDS_OFF) {
|
|
94
|
+
logger.debug('long-task-notifier: disabled (threshold=0)');
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Gate: only notify when the task exceeded the threshold.
|
|
99
|
+
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
100
|
+
if (elapsedSeconds < thresholdSeconds) {
|
|
101
|
+
logger.debug('long-task-notifier: below threshold', { elapsedSeconds, thresholdSeconds });
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build concise, metadata-only message. No conversation text.
|
|
106
|
+
const statusLabel = status === 'ok' ? 'completed' : 'failed';
|
|
107
|
+
const title = `GoodVibes — ${kind} ${statusLabel}`;
|
|
108
|
+
// PRIVACY: message contains only structural metadata, never conversation content.
|
|
109
|
+
const message = `${kind} ${statusLabel} in ${elapsedSeconds}s · session ${sessionId.slice(0, 8)}`;
|
|
110
|
+
|
|
111
|
+
// Delivery 1: desktop notification (notify-send on linux, osascript on mac).
|
|
112
|
+
// notifyCompletion is non-throwing; SDK handles platform absence silently.
|
|
113
|
+
try {
|
|
114
|
+
notifyCompletion(title, message, elapsedMs);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.debug('long-task-notifier: desktop notify error', { error: String(err) });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Delivery 2: outbound webhook (ntfy / generic endpoint) if configured.
|
|
120
|
+
if (webhookNotifier) {
|
|
121
|
+
const urls = webhookNotifier.getUrls();
|
|
122
|
+
if (urls.length > 0) {
|
|
123
|
+
webhookNotifier.send(message).catch((err: unknown) => {
|
|
124
|
+
logger.debug('long-task-notifier: webhook send error', { error: String(err) });
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
logger.debug('long-task-notifier: no webhook URLs configured, skipping outbound delivery');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read behavior.notifyAfterSeconds from a config manager.
|
|
136
|
+
* Returns NOTIFY_AFTER_SECONDS_DEFAULT when the key is absent or invalid.
|
|
137
|
+
* Returns NOTIFY_AFTER_SECONDS_OFF (0) when explicitly set to 0.
|
|
138
|
+
*/
|
|
139
|
+
export function readNotifyAfterSeconds(configGet: (key: string) => unknown): number {
|
|
140
|
+
const raw = configGet('behavior.notifyAfterSeconds');
|
|
141
|
+
if (raw === 0) return NOTIFY_AFTER_SECONDS_OFF;
|
|
142
|
+
const parsed = typeof raw === 'number' ? raw : Number(raw);
|
|
143
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
144
|
+
return NOTIFY_AFTER_SECONDS_DEFAULT;
|
|
145
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-recovery.ts — Journal replay at session resume.
|
|
3
|
+
*
|
|
4
|
+
* Purpose
|
|
5
|
+
* ───────
|
|
6
|
+
* When a session is resumed, this module checks whether a transcript journal
|
|
7
|
+
* exists for that session whose records post-date the loaded snapshot. If so,
|
|
8
|
+
* it replays those records onto the live conversation and writes a fresh
|
|
9
|
+
* snapshot so the gap is permanently closed.
|
|
10
|
+
*
|
|
11
|
+
* Seams (all three must call replayJournalForSession)
|
|
12
|
+
* ────────────────────────────────────────────────────
|
|
13
|
+
* 1. CLI / command resume — session-workflow.ts, after `fromJSON` +
|
|
14
|
+
* `rebuildHistory` complete. Handles --continue, --resume, /session resume,
|
|
15
|
+
* and --fork.
|
|
16
|
+
* 2. Ctrl+R crash recovery — blocking-input.ts, after `conversation.fromJSON`
|
|
17
|
+
* in the Ctrl+R branch. Handles SIGKILL-era recovery files.
|
|
18
|
+
* 3. In-TUI panel resume — bootstrap-hook-bridge.ts
|
|
19
|
+
* `createResumeSessionHandler`, after `options.runtime.sessionId` is
|
|
20
|
+
* assigned. Handles the session browser / panel-driven resume.
|
|
21
|
+
*
|
|
22
|
+
* Recovery protocol
|
|
23
|
+
* ─────────────────
|
|
24
|
+
* 1. Call replayJournal() with the journal path and the snapshot timestamp.
|
|
25
|
+
* 2. If no records are newer than the snapshot, rotate the (now-stale)
|
|
26
|
+
* journal silently and return.
|
|
27
|
+
* 3. If records are found, apply the final record's messages — each journal
|
|
28
|
+
* record carries the full conversation snapshot at that moment, so the
|
|
29
|
+
* last record by seq is the authoritative post-crash state.
|
|
30
|
+
* 4. Rebuild the conversation history and call the snapshot writer so the
|
|
31
|
+
* gap is durably closed before the user sees the restored conversation.
|
|
32
|
+
* 5. Rotate the journal (it is no longer needed as a gap-filler).
|
|
33
|
+
* 6. Return a result so the caller can emit an honest notice.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { journalPathFor, openTranscriptJournal, replayJournal } from './transcript-journal.ts';
|
|
37
|
+
import type { ConversationManager } from './conversation.ts';
|
|
38
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
39
|
+
|
|
40
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface ReplayIntoConversationOptions {
|
|
43
|
+
/** Absolute path to the journal file for this session. */
|
|
44
|
+
readonly journalPath: string;
|
|
45
|
+
/**
|
|
46
|
+
* The `timestamp` field from the loaded session snapshot (SessionMeta).
|
|
47
|
+
* Only journal records with ts > snapshotTimestamp are replayed.
|
|
48
|
+
*/
|
|
49
|
+
readonly snapshotTimestamp: number;
|
|
50
|
+
/** The live conversation manager to mutate with replayed messages. */
|
|
51
|
+
readonly conversation: ConversationManager;
|
|
52
|
+
/** Session ID — used when creating the post-replay journal instance for rotate(). */
|
|
53
|
+
readonly sessionId: string;
|
|
54
|
+
/**
|
|
55
|
+
* Persist the restored conversation so the gap is durably closed.
|
|
56
|
+
* Called with the final replayed message list. Best-effort — failures
|
|
57
|
+
* are swallowed so recovery never hard-fails a resume.
|
|
58
|
+
*/
|
|
59
|
+
readonly persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ReplayIntoConversationResult {
|
|
63
|
+
/** Number of journal records that post-dated the snapshot. 0 if nothing to replay. */
|
|
64
|
+
readonly replayed: number;
|
|
65
|
+
/** True if the journal tail was corrupt (quarantined). */
|
|
66
|
+
readonly hadCorruptTail: boolean;
|
|
67
|
+
/** True if the journal had an unrecognised schema version (quarantined). */
|
|
68
|
+
readonly hadVersionMismatch: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replay journal records newer than `snapshotTimestamp` onto `conversation`.
|
|
75
|
+
*
|
|
76
|
+
* Returns a result object so the caller can emit an appropriate notice.
|
|
77
|
+
* Never throws — all errors are swallowed to preserve the "best-effort"
|
|
78
|
+
* recovery contract.
|
|
79
|
+
*/
|
|
80
|
+
export function replayJournalIntoConversation(
|
|
81
|
+
options: ReplayIntoConversationOptions,
|
|
82
|
+
): ReplayIntoConversationResult {
|
|
83
|
+
const { journalPath, snapshotTimestamp, conversation, sessionId, persistSnapshot } = options;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const { records, hadCorruptTail } = replayJournal(journalPath, snapshotTimestamp);
|
|
87
|
+
|
|
88
|
+
// Detect version mismatch: replayJournal quarantines and returns
|
|
89
|
+
// hadCorruptTail=true + 0 records when the header version is wrong.
|
|
90
|
+
// We distinguish it from a genuine corrupt tail by checking whether the
|
|
91
|
+
// journal file still exists (quarantine renames it away in both cases,
|
|
92
|
+
// so we cannot inspect the header at this point). We surface both cases
|
|
93
|
+
// through hadCorruptTail to the caller; hadVersionMismatch is derived
|
|
94
|
+
// from it to give the caller a distinct notice option.
|
|
95
|
+
const hadVersionMismatch = hadCorruptTail && records.length === 0;
|
|
96
|
+
|
|
97
|
+
const journal = openTranscriptJournal(journalPath, sessionId);
|
|
98
|
+
|
|
99
|
+
if (records.length === 0) {
|
|
100
|
+
// Nothing to replay — rotate the (now-stale) journal silently.
|
|
101
|
+
journal.rotate();
|
|
102
|
+
return { replayed: 0, hadCorruptTail, hadVersionMismatch };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The last record (highest seq) holds the most recent full conversation
|
|
106
|
+
// state captured before the crash. Apply it.
|
|
107
|
+
const lastRecord = records[records.length - 1]!;
|
|
108
|
+
const replayedMessages = lastRecord.messages as ConversationMessageSnapshot[];
|
|
109
|
+
|
|
110
|
+
conversation.fromJSON({
|
|
111
|
+
messages: replayedMessages as never[],
|
|
112
|
+
});
|
|
113
|
+
conversation.rebuildHistory();
|
|
114
|
+
|
|
115
|
+
// Write a fresh snapshot so the gap is durably closed even if the
|
|
116
|
+
// process is killed again before the next turn-complete snapshot.
|
|
117
|
+
try {
|
|
118
|
+
persistSnapshot(replayedMessages);
|
|
119
|
+
} catch {
|
|
120
|
+
// Best-effort — never hard-fail recovery due to snapshot write failure.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Rotate the journal — it is no longer needed as a gap-filler.
|
|
124
|
+
journal.rotate();
|
|
125
|
+
|
|
126
|
+
return { replayed: records.length, hadCorruptTail, hadVersionMismatch: false };
|
|
127
|
+
} catch {
|
|
128
|
+
// Absolute last-resort guard — recovery must never crash a resume.
|
|
129
|
+
return { replayed: 0, hadCorruptTail: false, hadVersionMismatch: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the journal path for a given session and home directory, then call
|
|
135
|
+
* replayJournalIntoConversation().
|
|
136
|
+
*
|
|
137
|
+
* Convenience wrapper used by session-workflow.ts so it does not need to
|
|
138
|
+
* import journalPathFor directly.
|
|
139
|
+
*/
|
|
140
|
+
export function replayJournalForSession(
|
|
141
|
+
options: Omit<ReplayIntoConversationOptions, 'journalPath'> & {
|
|
142
|
+
readonly homeDirectory: string;
|
|
143
|
+
},
|
|
144
|
+
): ReplayIntoConversationResult {
|
|
145
|
+
const journalPath = journalPathFor(options.homeDirectory, options.sessionId);
|
|
146
|
+
return replayJournalIntoConversation({ ...options, journalPath });
|
|
147
|
+
}
|
|
@@ -60,6 +60,15 @@ interface StreamSystemMessageRouter {
|
|
|
60
60
|
low(message: string): void;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Minimal cost lookup surface for attaching cost-delta information to failover notices.
|
|
65
|
+
* Returns USD-per-1M-token pricing for the given model ID.
|
|
66
|
+
* The implementation may consult a catalog; if the model is unknown both fields are 0.
|
|
67
|
+
*/
|
|
68
|
+
export interface FailoverCostLookup {
|
|
69
|
+
getCostFromCatalog(modelId: string): { readonly input: number; readonly output: number };
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
export interface WireStreamEventMetricsOptions {
|
|
64
73
|
/** The UI runtime event bus (turns + tools sub-buses). */
|
|
65
74
|
readonly events: UiRuntimeEvents;
|
|
@@ -89,6 +98,13 @@ export interface WireStreamEventMetricsOptions {
|
|
|
89
98
|
* the optimizer is enabled and a viable next provider exists in the chain.
|
|
90
99
|
*/
|
|
91
100
|
readonly retryTurn?: () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Optional cost catalog for attaching per-1M-token cost information to
|
|
103
|
+
* the failover notice. When provided and both models have non-zero pricing,
|
|
104
|
+
* the notice includes input and output cost comparisons. When absent or pricing is
|
|
105
|
+
* unavailable for either model, the notice honestly states "cost data unavailable".
|
|
106
|
+
*/
|
|
107
|
+
readonly costLookup?: FailoverCostLookup;
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
/** Result of wireStreamEventMetrics. */
|
|
@@ -102,6 +118,53 @@ export interface WireStreamEventMetricsResult {
|
|
|
102
118
|
* TURN_COMPLETED, but a new submission may arrive before TURN_COMPLETED fires).
|
|
103
119
|
*/
|
|
104
120
|
readonly clearFailoverVisited: () => void;
|
|
121
|
+
/**
|
|
122
|
+
* Register a callback that fires whenever a TURN_ERROR is surfaced to the
|
|
123
|
+
* user — either immediately (no optimizer) or after chain exhaustion.
|
|
124
|
+
* Does NOT fire when the optimizer performs a successful automatic failover
|
|
125
|
+
* (in that case the user sees a [Failover] notice, not an error).
|
|
126
|
+
* Used by main.ts to activate the one-key retry affordance. The callback
|
|
127
|
+
* receives exhausted=true when the failover chain was exhausted first, so
|
|
128
|
+
* the notice can say honestly that a retry reuses the same failed provider.
|
|
129
|
+
*/
|
|
130
|
+
readonly onErrorSurfaced: (cb: (exhausted: boolean) => void) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the cost-delta suffix for a failover notice.
|
|
135
|
+
*
|
|
136
|
+
* Extracts the model ID from registry keys (format: `provider:modelId`),
|
|
137
|
+
* queries the cost catalog for both, and formats a human-readable comparison.
|
|
138
|
+
* If the lookup is absent or either model returns zero pricing (unknown),
|
|
139
|
+
* returns an honest "cost data unavailable" suffix instead of fabricating values.
|
|
140
|
+
*
|
|
141
|
+
* @param lookup - Optional cost catalog; when absent, returns unavailable notice.
|
|
142
|
+
* @param fromRegistryKey - Registry key of the provider being abandoned (may be undefined).
|
|
143
|
+
* @param toRegistryKey - Registry key of the provider being selected.
|
|
144
|
+
* @returns A parenthesised suffix string or empty string.
|
|
145
|
+
*/
|
|
146
|
+
function buildCostDeltaSuffix(
|
|
147
|
+
lookup: FailoverCostLookup | undefined,
|
|
148
|
+
fromRegistryKey: string | undefined,
|
|
149
|
+
toRegistryKey: string,
|
|
150
|
+
): string {
|
|
151
|
+
if (!lookup) return '';
|
|
152
|
+
// Registry key format: `provider:modelId` — modelId may itself contain `:`.
|
|
153
|
+
const fromModelId = fromRegistryKey ? fromRegistryKey.split(':').slice(1).join(':') : '';
|
|
154
|
+
const toModelId = toRegistryKey.split(':').slice(1).join(':');
|
|
155
|
+
const fromCost = fromModelId ? lookup.getCostFromCatalog(fromModelId) : { input: 0, output: 0 };
|
|
156
|
+
const toCost = lookup.getCostFromCatalog(toModelId);
|
|
157
|
+
// Report unavailable when either side has zero pricing (unknown model).
|
|
158
|
+
if (fromCost.input === 0 && fromCost.output === 0 && !fromModelId) {
|
|
159
|
+
return ' [cost data unavailable]';
|
|
160
|
+
}
|
|
161
|
+
const hasFromData = fromCost.input > 0 || fromCost.output > 0;
|
|
162
|
+
const hasToData = toCost.input > 0 || toCost.output > 0;
|
|
163
|
+
if (!hasFromData || !hasToData) {
|
|
164
|
+
return ' [cost data unavailable]';
|
|
165
|
+
}
|
|
166
|
+
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|
167
|
+
return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
|
|
105
168
|
}
|
|
106
169
|
|
|
107
170
|
/**
|
|
@@ -123,7 +186,7 @@ export function wireStreamEventMetrics(
|
|
|
123
186
|
): WireStreamEventMetricsResult {
|
|
124
187
|
const {
|
|
125
188
|
events, metrics, orchestrator, providerRegistry,
|
|
126
|
-
systemMessageRouter, render, providerOptimizer, retryTurn,
|
|
189
|
+
systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
|
|
127
190
|
} = options;
|
|
128
191
|
|
|
129
192
|
const unsubs: Array<() => void> = [];
|
|
@@ -192,6 +255,8 @@ export function wireStreamEventMetrics(
|
|
|
192
255
|
if (next) {
|
|
193
256
|
const toRegistryKey = `${next.providerId}:${next.modelId}`;
|
|
194
257
|
const errorClass = formatUserFacingErrorLine(errVal);
|
|
258
|
+
// Capture FROM registry key before switching — needed for cost comparison.
|
|
259
|
+
const fromRegistryKey = providerRegistry.getCurrentModel().registryKey;
|
|
195
260
|
try {
|
|
196
261
|
providerRegistry.setCurrentModel(toRegistryKey);
|
|
197
262
|
} catch (switchErr) {
|
|
@@ -205,8 +270,9 @@ export function wireStreamEventMetrics(
|
|
|
205
270
|
// a subsequent TURN_ERROR from that provider also skips it.
|
|
206
271
|
failoverVisited.add(next.providerId);
|
|
207
272
|
providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
|
|
273
|
+
const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
|
|
208
274
|
systemMessageRouter.high(
|
|
209
|
-
`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})`,
|
|
275
|
+
`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${costSuffix}`,
|
|
210
276
|
);
|
|
211
277
|
render();
|
|
212
278
|
// Re-submit the last user turn on the new provider.
|
|
@@ -218,6 +284,7 @@ export function wireStreamEventMetrics(
|
|
|
218
284
|
systemMessageRouter.high(
|
|
219
285
|
`[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
|
|
220
286
|
);
|
|
287
|
+
notifyErrorSurfaced(true);
|
|
221
288
|
render();
|
|
222
289
|
return;
|
|
223
290
|
}
|
|
@@ -225,6 +292,7 @@ export function wireStreamEventMetrics(
|
|
|
225
292
|
// Baseline: optimizer disabled or not wired — surface error immediately.
|
|
226
293
|
const formatted = formatUserFacingErrorLine(errVal);
|
|
227
294
|
systemMessageRouter.high(`[Error] ${formatted}`);
|
|
295
|
+
notifyErrorSurfaced(false);
|
|
228
296
|
render();
|
|
229
297
|
}));
|
|
230
298
|
|
|
@@ -258,5 +326,11 @@ export function wireStreamEventMetrics(
|
|
|
258
326
|
metrics.activeToolName = undefined;
|
|
259
327
|
}));
|
|
260
328
|
|
|
261
|
-
|
|
329
|
+
let _errorSurfacedCb: ((exhausted: boolean) => void) | undefined;
|
|
330
|
+
function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
|
|
331
|
+
return {
|
|
332
|
+
unsubs,
|
|
333
|
+
clearFailoverVisited: () => failoverVisited.clear(),
|
|
334
|
+
onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
|
|
335
|
+
};
|
|
262
336
|
}
|