@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +36 -334
- package/src/cli/surface-command.ts +1 -1
- package/src/core/context-auto-compact.ts +43 -10
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/stream-event-wiring.ts +125 -7
- package/src/input/commands/channel-runtime.ts +139 -0
- package/src/input/commands/runtime-services.ts +30 -1
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +54 -4
- package/src/input/commands.ts +2 -0
- package/src/main.ts +26 -26
- package/src/renderer/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- package/src/renderer/settings-modal-helpers.ts +2 -2
- package/src/runtime/bootstrap-core.ts +92 -0
- package/src/utils/browser.ts +29 -0
- package/src/version.ts +1 -1
|
@@ -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
|
|
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
|
-
):
|
|
79
|
-
const {
|
|
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
|
-
|
|
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 '../../
|
|
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.
|
|
208
|
-
|
|
209
|
-
|
|
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
|
});
|
package/src/input/commands.ts
CHANGED
|
@@ -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
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
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
|
-
|
|
319
|
-
if (options.spokenOutput && processedText) {
|
|
320
|
-
|
|
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
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
orchestrator,
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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(...
|
|
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
|
+
}
|