@pellux/goodvibes-tui 0.21.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/management-commands.ts +1 -1
  7. package/src/cli/management-utils.ts +352 -0
  8. package/src/cli/management.ts +36 -334
  9. package/src/cli/parser.ts +17 -0
  10. package/src/cli/surface-command.ts +1 -1
  11. package/src/cli/types.ts +2 -0
  12. package/src/config/goodvibes-home-audit.ts +2 -0
  13. package/src/core/context-auto-compact.ts +110 -0
  14. package/src/core/conversation-rendering.ts +5 -2
  15. package/src/core/conversation-types.ts +24 -0
  16. package/src/core/conversation.ts +7 -12
  17. package/src/core/stream-event-wiring.ts +125 -7
  18. package/src/core/turn-event-wiring.ts +124 -0
  19. package/src/daemon/cli.ts +5 -0
  20. package/src/input/command-registry.ts +1 -0
  21. package/src/input/commands/channel-runtime.ts +139 -0
  22. package/src/input/commands/control-room-runtime.ts +5 -5
  23. package/src/input/commands/provider.ts +57 -3
  24. package/src/input/commands/runtime-services.ts +30 -1
  25. package/src/input/commands/session-workflow.ts +8 -16
  26. package/src/input/commands/session.ts +70 -20
  27. package/src/input/commands/share-runtime.ts +1 -1
  28. package/src/input/commands/shell-core.ts +54 -4
  29. package/src/input/commands.ts +2 -2
  30. package/src/input/handler-modal-routes.ts +37 -0
  31. package/src/input/handler-modal-token-routes.ts +19 -5
  32. package/src/input/handler-onboarding.ts +18 -0
  33. package/src/input/handler.ts +1 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  35. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  36. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  37. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  38. package/src/input/settings-modal-behavior.ts +5 -0
  39. package/src/input/settings-modal-data.ts +77 -3
  40. package/src/input/settings-modal-mutations.ts +3 -0
  41. package/src/input/settings-modal-reset.ts +154 -0
  42. package/src/input/settings-modal.ts +55 -13
  43. package/src/main.ts +58 -50
  44. package/src/panels/agent-inspector-panel.ts +120 -18
  45. package/src/panels/agent-inspector-shared.ts +29 -0
  46. package/src/panels/builtin/development.ts +1 -0
  47. package/src/panels/builtin/knowledge.ts +14 -13
  48. package/src/panels/builtin/operations.ts +22 -1
  49. package/src/panels/builtin/shared.ts +7 -0
  50. package/src/panels/cockpit-panel.ts +123 -3
  51. package/src/panels/cockpit-read-model.ts +232 -0
  52. package/src/panels/index.ts +1 -1
  53. package/src/panels/knowledge-graph-panel.ts +84 -0
  54. package/src/panels/memory-panel.ts +370 -40
  55. package/src/panels/session-maintenance.ts +66 -15
  56. package/src/renderer/agent-detail-modal.ts +107 -3
  57. package/src/renderer/compaction-history-modal.ts +55 -0
  58. package/src/renderer/compaction-preview.ts +146 -0
  59. package/src/renderer/context-status-hint.ts +54 -0
  60. package/src/renderer/settings-modal-helpers.ts +2 -2
  61. package/src/renderer/settings-modal.ts +14 -3
  62. package/src/renderer/shell-surface.ts +10 -0
  63. package/src/runtime/bootstrap-command-parts.ts +4 -0
  64. package/src/runtime/bootstrap-core.ts +116 -0
  65. package/src/runtime/bootstrap-shell.ts +11 -0
  66. package/src/runtime/bootstrap.ts +7 -0
  67. package/src/runtime/services.ts +6 -1
  68. package/src/utils/browser.ts +29 -0
  69. package/src/version.ts +1 -1
  70. package/src/panels/knowledge-panel.ts +0 -343
@@ -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,124 @@
1
+ import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
+ import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
+ import { maybeAutoCompact } from './context-auto-compact.ts';
4
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+ import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
7
+ import type { ConversationManager } from './conversation.ts';
8
+
9
+ /** Infer the options param of persistConversation to pick up SessionManager correctly. */
10
+ type PersistOptions = NonNullable<Parameters<typeof persistConversation>[5]>;
11
+
12
+ /** Minimal orchestrator surface required by turn-event wiring. */
13
+ interface TurnOrchestrator {
14
+ readonly lastInputTokens: number;
15
+ }
16
+
17
+ /** Minimal provider registry surface required by turn-event wiring. */
18
+ interface TurnProviderRegistry {
19
+ getCurrentModel(): { readonly contextWindow: number };
20
+ }
21
+
22
+ /** Minimal config manager surface required by turn-event wiring. */
23
+ interface TurnConfigManager {
24
+ get(key: string): unknown;
25
+ }
26
+
27
+ /** Minimal system message router surface required by turn-event wiring. */
28
+ interface TurnSystemMessageRouter {
29
+ high(message: string): void;
30
+ low(message: string): void;
31
+ routeSystemMessage(message: string, level: string): void;
32
+ }
33
+
34
+ export interface WireTurnEventHandlersOptions {
35
+ readonly events: UiRuntimeEvents;
36
+ readonly conversation: ConversationManager;
37
+ readonly runtime: { sessionId: string; model: string; provider: string };
38
+ readonly orchestrator: TurnOrchestrator;
39
+ readonly configManager: TurnConfigManager;
40
+ readonly providerRegistry: TurnProviderRegistry;
41
+ readonly systemMessageRouter: TurnSystemMessageRouter;
42
+ readonly hookDispatcher: HookDispatcher;
43
+ readonly workingDir: string;
44
+ readonly homeDirectory: string;
45
+ readonly sessionManager: PersistOptions['sessionManager'];
46
+ readonly gitStatusProvider: { refresh(): Promise<unknown> };
47
+ readonly lastGitInfoRef: { value: unknown };
48
+ readonly buildSessionContinuityHints: () => Record<string, unknown>;
49
+ readonly render: () => void;
50
+ }
51
+
52
+ export interface WireTurnEventHandlersResult {
53
+ /** Trigger a git status refresh; may be called from external code after tool execution. */
54
+ readonly refreshGit: () => void;
55
+ /** Unsubscribe functions to push into the parent unsubs array. */
56
+ readonly unsubs: ReadonlyArray<() => void>;
57
+ }
58
+
59
+ /**
60
+ * Wire TURN_COMPLETED, TOOL_SUCCEEDED, and TOOL_FAILED runtime events.
61
+ *
62
+ * Responsibilities:
63
+ * - Auto-save conversation to persistent store after each LLM turn
64
+ * - Fire the Lifecycle:session:save hook
65
+ * - Trigger auto-compact when context usage exceeds the configured threshold
66
+ * - Refresh git status after turns and tool results
67
+ *
68
+ * Returns refreshGit (callable externally) and unsubs (push into parent unsubs).
69
+ */
70
+ export function wireTurnEventHandlers(
71
+ options: WireTurnEventHandlersOptions,
72
+ ): WireTurnEventHandlersResult {
73
+ const {
74
+ events, conversation, runtime, orchestrator, configManager,
75
+ providerRegistry, systemMessageRouter, hookDispatcher,
76
+ workingDir, homeDirectory, sessionManager, gitStatusProvider,
77
+ lastGitInfoRef, buildSessionContinuityHints, render,
78
+ } = options;
79
+
80
+ const unsubs: Array<() => void> = [];
81
+
82
+ const refreshGit = (): void => {
83
+ gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
84
+ };
85
+
86
+ unsubs.push(events.turns.on('TURN_COMPLETED', () => {
87
+ // Auto-save after every LLM turn so kills don't lose the session
88
+ try {
89
+ const snapshot = conversation.toJSON() as { messages: Array<import('./conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
90
+ const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
91
+ persistConversation(
92
+ runtime.sessionId,
93
+ { ...snapshot, ...persisted },
94
+ runtime.model,
95
+ runtime.provider,
96
+ conversation.title || '',
97
+ { workingDirectory: workingDir, homeDirectory, sessionManager },
98
+ );
99
+ hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
100
+ } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
101
+ // Auto-compact: check context usage and compact if threshold exceeded
102
+ const currentModelForCompact = providerRegistry.getCurrentModel();
103
+ maybeAutoCompact({
104
+ configManager: configManager as Parameters<typeof maybeAutoCompact>[0]['configManager'],
105
+ conversation,
106
+ providerRegistry: providerRegistry as Parameters<typeof maybeAutoCompact>[0]['providerRegistry'],
107
+ systemMessageRouter: systemMessageRouter as Parameters<typeof maybeAutoCompact>[0]['systemMessageRouter'],
108
+ model: runtime.model,
109
+ provider: runtime.provider,
110
+ lastInputTokens: orchestrator.lastInputTokens,
111
+ contextWindow: currentModelForCompact.contextWindow,
112
+ }).catch((err: unknown) => logger.debug('maybeAutoCompact error', { error: summarizeError(err) }));
113
+ refreshGit();
114
+ }));
115
+
116
+ unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
117
+ refreshGit();
118
+ }));
119
+ unsubs.push(events.tools.on('TOOL_FAILED', () => {
120
+ refreshGit();
121
+ }));
122
+
123
+ return { refreshGit, unsubs };
124
+ }
package/src/daemon/cli.ts CHANGED
@@ -104,6 +104,11 @@ async function main(): Promise<void> {
104
104
  console.error(renderGoodVibesDaemonHelp('goodvibes-daemon'));
105
105
  process.exit(2);
106
106
  }
107
+ if (cli.warnings.length > 0) {
108
+ for (const warning of cli.warnings) {
109
+ console.warn(`[goodvibes-daemon] warning: ${warning}`);
110
+ }
111
+ }
107
112
  if (cli.flags.help || cli.command === 'help') {
108
113
  console.log(renderGoodVibesDaemonHelp('goodvibes-daemon'));
109
114
  process.exit(0);
@@ -118,6 +118,7 @@ export interface CommandShellUiOpeners {
118
118
  openMcpWorkspace?: () => void;
119
119
  openSecurityPanel?: () => void;
120
120
  openKnowledgePanel?: () => void;
121
+ openMemoryPanel?: () => void;
121
122
  openRemotePanel?: () => void;
122
123
  openSubscriptionPanel?: () => void;
123
124
  /**
@@ -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
+ }
@@ -203,11 +203,11 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
203
203
  handler(args, ctx) {
204
204
  const subcommand = (args[0] ?? 'open').toLowerCase();
205
205
  if (subcommand === 'open') {
206
- if (ctx.openKnowledgePanel) {
207
- ctx.openKnowledgePanel();
206
+ if (ctx.openMemoryPanel) {
207
+ ctx.openMemoryPanel();
208
208
  return;
209
209
  }
210
- ctx.print('Knowledge panel is not available in this runtime.');
210
+ ctx.print('Memory panel is not available in this runtime.');
211
211
  return;
212
212
  }
213
213
  const memory = getMemoryApi(ctx);
@@ -245,8 +245,8 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
245
245
  ctx.print(prompt ?? 'No reviewed project knowledge matched that task.');
246
246
  return;
247
247
  }
248
- if (ctx.openKnowledgePanel) {
249
- ctx.openKnowledgePanel();
248
+ if (ctx.openMemoryPanel) {
249
+ ctx.openMemoryPanel();
250
250
  return;
251
251
  }
252
252
  ctx.print(`Unknown project-memory subcommand: ${subcommand}`);
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Implements the Provider Optimizer panel commands:
5
5
  *
6
+ * /provider optimizer on|off — Enable or disable the provider optimizer
6
7
  * /provider route auto|manual — Set optimizer routing mode
7
8
  * /provider explain-route — Print current route explanation
8
9
  * /provider pin <provider:model> — Pin routing to a specific provider/model
@@ -10,14 +11,19 @@
10
11
  *
11
12
  * When the optimizer is disabled, commands report its status and
12
13
  * explain-route still works (reads current model capabilities).
14
+ * Enabling the optimizer persists the change to config so it survives restart.
13
15
  */
14
16
 
15
17
  import type { SlashCommand, CommandContext } from '../command-registry.ts';
18
+ import type { ConfigKey } from '../../config/index.ts';
16
19
  import type { RouteExplanation } from '@pellux/goodvibes-sdk/platform/providers';
17
20
  import type { FallbackTestResult, FallbackTransition } from '@pellux/goodvibes-sdk/platform/providers';
18
21
  import type { ProviderApiModelRecord } from '@pellux/goodvibes-sdk/platform/providers';
19
22
  import { requireProviderApi } from './runtime-services.ts';
20
23
 
24
+ const PROVIDER_OPTIMIZER_FLAG = 'provider-optimizer';
25
+ const PROVIDER_OPTIMIZER_CONFIG_KEY = `featureFlags.${PROVIDER_OPTIMIZER_FLAG}` as ConfigKey;
26
+
21
27
  // ---------------------------------------------------------------------------
22
28
  // Formatting helpers
23
29
  // ---------------------------------------------------------------------------
@@ -65,6 +71,49 @@ function fmtExplanation(expl: RouteExplanation, context: CommandContext): void {
65
71
  }
66
72
  }
67
73
 
74
+ // ---------------------------------------------------------------------------
75
+ // /provider optimizer on|off
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function handleOptimizerToggle(
79
+ args: string[],
80
+ context: CommandContext,
81
+ ): void {
82
+ const optimizer = requireProviderOptimizer(context);
83
+ if (!optimizer) return;
84
+ const sub = args[0];
85
+
86
+ if (sub !== 'on' && sub !== 'off') {
87
+ context.print('[provider] Usage: /provider optimizer on|off');
88
+ context.print(` Current state: optimizer is ${optimizer.enabled ? 'enabled' : 'disabled'}`);
89
+ context.print(' "on" — activates intelligent failover and auto-routing');
90
+ context.print(' "off" — disables optimizer; provider selection is manual only');
91
+ return;
92
+ }
93
+
94
+ const enable = sub === 'on';
95
+ const wasEnabled = optimizer.enabled;
96
+ optimizer.setEnabled(enable);
97
+
98
+ // Persist to config so the setting survives restart.
99
+ const flagValue = enable ? 'enabled' : 'disabled';
100
+ context.platform.configManager.setDynamic(PROVIDER_OPTIMIZER_CONFIG_KEY, flagValue);
101
+
102
+ if (enable && !wasEnabled) {
103
+ context.print('[provider] Optimizer enabled.');
104
+ context.print(' Intelligent failover is now active: on a request error the optimizer');
105
+ context.print(' will attempt the next viable provider and surface a transcript notice');
106
+ context.print(' naming the from→to transition and reason before retrying.');
107
+ context.print(' Use "/provider route auto" to enable fully automatic routing.');
108
+ } else if (!enable && wasEnabled) {
109
+ context.print('[provider] Optimizer disabled.');
110
+ context.print(' Provider selection returns to manual-only mode. No automatic failover.');
111
+ context.print(' Pinned targets and fallback log are preserved; re-enable to resume.');
112
+ } else {
113
+ context.print(`[provider] Optimizer already ${enable ? 'enabled' : 'disabled'} — no change.`);
114
+ }
115
+ }
116
+
68
117
  // ---------------------------------------------------------------------------
69
118
  // /provider route auto|manual
70
119
  // ---------------------------------------------------------------------------
@@ -85,9 +134,9 @@ function handleRoute(
85
134
 
86
135
  if (!optimizer.enabled) {
87
136
  context.print(
88
- '[provider] Optimizer is currently disabled. Enable it with the provider-optimizer feature flag.',
137
+ '[provider] Optimizer is off routing mode recorded but failover will not fire until optimizer is enabled.',
89
138
  );
90
- context.print(` Routing mode set to: ${sub} (no-op until optimizer is enabled)`);
139
+ context.print(' Enable with: /provider optimizer on');
91
140
  }
92
141
 
93
142
  optimizer.setMode(sub);
@@ -318,11 +367,15 @@ export const providerCommand: SlashCommand = {
318
367
  aliases: ['prov-opt'],
319
368
  description: 'Manage provider routing optimizer (route, pin, explain, fallback).',
320
369
  usage: '<subcommand> [args]',
321
- argsHint: 'route|explain-route|pin|fallback',
370
+ argsHint: 'optimizer|route|explain-route|pin|fallback',
322
371
  handler: async (args: string[], context: CommandContext): Promise<void> => {
323
372
  const [sub, ...rest] = args;
324
373
 
325
374
  switch (sub) {
375
+ case 'optimizer':
376
+ handleOptimizerToggle(rest, context);
377
+ break;
378
+
326
379
  case 'route':
327
380
  handleRoute(rest, context);
328
381
  break;
@@ -345,6 +398,7 @@ export const providerCommand: SlashCommand = {
345
398
  if (!optimizer) return;
346
399
  const lines = [
347
400
  'Usage: /provider <subcommand>',
401
+ ' optimizer on|off — Enable or disable the provider optimizer',
348
402
  ' route auto|manual — Set optimizer routing mode',
349
403
  ' explain-route — Show current route explanation',
350
404
  ' pin <provider:model> — Pin routing to specific provider/model',
@@ -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 {