@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,18 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import net from 'node:net';
4
- import { spawn } from 'node:child_process';
5
- import { networkInterfaces } from 'node:os';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
6
3
  import type { ConfigManager, ConfigKey, GoodVibesConfig } from '../config/index.ts';
7
4
  import { CONFIG_SCHEMA } from '../config/index.ts';
8
5
  import { formatProviderModel, getModelIdFromProviderModel } from '../config/provider-model.ts';
9
- import { bootstrapRuntime } from '../runtime/bootstrap.ts';
10
- import { createRuntimeServices } from '../runtime/services.ts';
11
- import { createRuntimeStore } from '../runtime/store/index.ts';
12
- import type { RuntimeServices } from '../runtime/services.ts';
13
6
  import { SecretsManager } from '../config/secrets.ts';
14
- import { RuntimeEventBus, type TurnEvent } from '@/runtime/index.ts';
15
- import { createShellPathService } from '@/runtime/index.ts';
16
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
17
8
  import { listProviderRuntimeSnapshots } from '@pellux/goodvibes-sdk/platform/providers';
18
9
  import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, resolveSecretRef } from '@pellux/goodvibes-sdk/platform/config';
@@ -31,105 +22,46 @@ import { handleServiceCommand } from './service-command.ts';
31
22
  import { handleBundleCommand } from './bundle-command.ts';
32
23
  import { buildListenerTestResult, formatListenerTestResult, handleSurfacesCommand } from './surface-command.ts';
33
24
  import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets, handleSessions, handleTasks, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
25
+ import {
26
+ yesNo,
27
+ formatJsonOrText,
28
+ hasCommandFlag,
29
+ withRuntimeServices,
30
+ runNonInteractiveAgent,
31
+ isPresentConfigValue,
32
+ urlHostForBindHost,
33
+ probeTcp,
34
+ readAuthPaths,
35
+ exitCodeForText,
36
+ splitCommandOption,
37
+ readOptionValue,
38
+ readOptionValues,
39
+ commandValues,
40
+ readPassword,
41
+ } from './management-utils.ts';
34
42
 
35
43
  interface CliCommandResult {
36
44
  readonly handled: boolean;
37
45
  readonly exitCode: number;
38
46
  }
39
47
 
40
- type Formatter = (value: unknown, text: string) => string;
41
-
42
- export function yesNo(value: unknown): string {
43
- return value === true ? 'yes' : 'no';
44
- }
45
-
46
- export function formatJsonOrText(cli: GoodVibesCliParseResult): Formatter {
47
- return (value, text) => cli.flags.outputFormat === 'json'
48
- ? JSON.stringify(value, null, 2)
49
- : text;
50
- }
51
-
52
- function exitCodeForText(output: string): number {
53
- if (output.startsWith('Usage:') || output.startsWith('Invalid ')) return 2;
54
- if (output.startsWith('Session not found:') || output.startsWith('Unknown task:') || output.startsWith('Task submit failed ')) return 1;
55
- if (output.startsWith('No stored ') || output.startsWith('No pending ') || output.startsWith('No model ') || output.startsWith('No provider ') || output.startsWith('No auth ')) return 1;
56
- if (output.startsWith('Unknown ')) return 1;
57
- if (output === 'Bundle has no config object to import.') return 1;
58
- return 0;
59
- }
60
-
61
- function splitCommandOption(token: string): { readonly name: string; readonly value: string | undefined } {
62
- const index = token.indexOf('=');
63
- if (index < 0) return { name: token, value: undefined };
64
- return { name: token.slice(0, index), value: token.slice(index + 1) };
65
- }
66
-
67
- function readOptionValue(args: readonly string[], name: string): string | undefined {
68
- for (let index = 0; index < args.length; index += 1) {
69
- const token = args[index]!;
70
- const split = splitCommandOption(token);
71
- if (split.name !== name) continue;
72
- if (split.value !== undefined) return split.value;
73
- const next = args[index + 1];
74
- if (next === undefined || next.startsWith('--')) return undefined;
75
- return next;
76
- }
77
- return undefined;
78
- }
79
-
80
- function readOptionValues(args: readonly string[], name: string): string[] {
81
- const values: string[] = [];
82
- for (let index = 0; index < args.length; index += 1) {
83
- const token = args[index]!;
84
- const split = splitCommandOption(token);
85
- if (split.name !== name) continue;
86
- if (split.value !== undefined) {
87
- values.push(split.value);
88
- continue;
89
- }
90
- const next = args[index + 1];
91
- if (next !== undefined && !next.startsWith('--')) values.push(next);
92
- }
93
- return values;
94
- }
95
-
96
- export function hasCommandFlag(args: readonly string[], name: string): boolean {
97
- return args.some((arg) => splitCommandOption(arg).name === name);
98
- }
99
-
100
- function commandValues(args: readonly string[]): string[] {
101
- const values: string[] = [];
102
- for (let index = 0; index < args.length; index += 1) {
103
- const token = args[index]!;
104
- if (!token.startsWith('--')) {
105
- values.push(token);
106
- continue;
107
- }
108
- if (!token.includes('=') && args[index + 1] && !args[index + 1]!.startsWith('--')) index += 1;
109
- }
110
- return values;
111
- }
112
-
113
- function readPassword(args: readonly string[]): string | null {
114
- const explicit = readOptionValue(args, '--password');
115
- if (explicit !== undefined) return explicit;
116
- if (hasCommandFlag(args, '--password-stdin')) return readFileSync(0, 'utf-8').trimEnd();
117
- return process.env.GOODVIBES_AUTH_PASSWORD ?? null;
118
- }
119
-
120
- export function extractAuthorizationCode(input: string): string {
121
- try {
122
- const url = new URL(input);
123
- return url.searchParams.get('code') ?? input;
124
- } catch {
125
- return input;
126
- }
127
- }
128
-
129
- export function isPresentConfigValue(value: unknown): boolean {
130
- if (typeof value === 'string') return value.trim().length > 0;
131
- return value !== undefined && value !== null && value !== false;
132
- }
48
+ export {
49
+ yesNo,
50
+ formatJsonOrText,
51
+ hasCommandFlag,
52
+ extractAuthorizationCode,
53
+ isPresentConfigValue,
54
+ getNestedValue,
55
+ urlHostForBindHost,
56
+ enableServicePosture,
57
+ enableEndpointLanDefault,
58
+ applyTargetEndpointFlagsOrDefault,
59
+ openBrowser,
60
+ probeTcp,
61
+ withRuntimeServices,
62
+ readAuthPaths,
63
+ runNonInteractiveAgent,
64
+ } from './management-utils.ts';
133
65
 
134
66
  function inferProviderFromRegistryKey(modelKey: string): string {
135
67
  if (modelKey.includes(':')) return modelKey.split(':')[0] || 'openai';
@@ -137,236 +69,6 @@ function inferProviderFromRegistryKey(modelKey: string): string {
137
69
  return 'openai';
138
70
  }
139
71
 
140
- export function getNestedValue(source: unknown, key: string): unknown {
141
- let cursor = source;
142
- for (const part of key.split('.')) {
143
- if (cursor == null || typeof cursor !== 'object') return undefined;
144
- cursor = (cursor as Record<string, unknown>)[part];
145
- }
146
- return cursor;
147
- }
148
-
149
- function getLocalNetworkIp(): string {
150
- try {
151
- const nets = networkInterfaces();
152
- for (const name of Object.keys(nets)) {
153
- for (const netInfo of nets[name] ?? []) {
154
- if (netInfo.family === 'IPv4' && !netInfo.internal) return netInfo.address;
155
- }
156
- }
157
- } catch {
158
- return '127.0.0.1';
159
- }
160
- return '127.0.0.1';
161
- }
162
-
163
- function connectHostForBindHost(host: string): string {
164
- if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
165
- return host || '127.0.0.1';
166
- }
167
-
168
- export function urlHostForBindHost(host: string): string {
169
- if (host === '0.0.0.0' || host === '::') return getLocalNetworkIp();
170
- return host || '127.0.0.1';
171
- }
172
-
173
- export function enableServicePosture(config: ConfigManager): void {
174
- config.setDynamic('service.enabled', true);
175
- config.setDynamic('service.autostart', true);
176
- config.setDynamic('service.restartOnFailure', true);
177
- }
178
-
179
- export function enableEndpointLanDefault(config: ConfigManager, endpoint: RuntimeEndpointId): void {
180
- const binding = resolveRuntimeEndpointBinding(config, endpoint);
181
- if (binding.hostMode === 'custom') return;
182
- if (endpoint === 'controlPlane') {
183
- config.setDynamic('controlPlane.hostMode', 'network');
184
- config.setDynamic('controlPlane.host', '0.0.0.0');
185
- config.setDynamic('controlPlane.allowRemote', true);
186
- return;
187
- }
188
- if (endpoint === 'httpListener') {
189
- config.setDynamic('httpListener.hostMode', 'network');
190
- config.setDynamic('httpListener.host', '0.0.0.0');
191
- return;
192
- }
193
- config.setDynamic('web.hostMode', 'network');
194
- config.setDynamic('web.host', '0.0.0.0');
195
- }
196
-
197
- export function applyTargetEndpointFlagsOrDefault(
198
- runtime: CliCommandRuntime,
199
- endpoint: RuntimeEndpointId,
200
- ): string | null {
201
- const errors = applyRuntimeEndpointFlagOverrides(runtime.configManager, endpoint, runtime.cli.flags);
202
- if (errors.length > 0) return errors.join('\n');
203
- if (runtime.cli.flags.hostname === undefined) {
204
- enableEndpointLanDefault(runtime.configManager, endpoint);
205
- }
206
- if (endpoint === 'controlPlane') {
207
- const binding = resolveRuntimeEndpointBinding(runtime.configManager, endpoint);
208
- runtime.configManager.setDynamic('controlPlane.allowRemote', binding.hostMode !== 'local');
209
- }
210
- return null;
211
- }
212
-
213
- export function openBrowser(url: string): string {
214
- const platform = process.platform;
215
- const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
216
- const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
217
- try {
218
- const child = spawn(command, args, { detached: true, stdio: 'ignore' });
219
- child.once('error', () => {});
220
- child.unref();
221
- return 'browser open requested';
222
- } catch (error) {
223
- return `browser open failed: ${summarizeError(error)}`;
224
- }
225
- }
226
-
227
- export async function probeTcp(host: string, port: number, timeoutMs = 750): Promise<boolean> {
228
- return await new Promise<boolean>((resolve) => {
229
- const socket = net.createConnection({ host: connectHostForBindHost(host), port });
230
- const finish = (value: boolean) => {
231
- socket.removeAllListeners();
232
- socket.destroy();
233
- resolve(value);
234
- };
235
- socket.setTimeout(timeoutMs);
236
- socket.once('connect', () => finish(true));
237
- socket.once('timeout', () => finish(false));
238
- socket.once('error', () => finish(false));
239
- });
240
- }
241
-
242
- export async function withRuntimeServices<T>(
243
- runtime: CliCommandRuntime,
244
- fn: (services: RuntimeServices) => Promise<T> | T,
245
- ): Promise<T> {
246
- const runtimeBus = new RuntimeEventBus();
247
- const runtimeStore = createRuntimeStore();
248
- const services = createRuntimeServices({
249
- configManager: runtime.configManager,
250
- runtimeBus,
251
- runtimeStore,
252
- workingDir: runtime.workingDirectory,
253
- homeDirectory: runtime.homeDirectory,
254
- });
255
- services.providerRegistry.initModelLimits();
256
- services.benchmarkStore.initBenchmarks();
257
- services.providerRegistry.initCatalog();
258
- try {
259
- await services.providerRegistry.ready();
260
- return await fn(services);
261
- } finally {
262
- services.providerRegistry.stopWatching();
263
- }
264
- }
265
-
266
- export function readAuthPaths(runtime: CliCommandRuntime) {
267
- const shellPaths = createShellPathService({
268
- workingDirectory: runtime.workingDirectory,
269
- homeDirectory: runtime.homeDirectory,
270
- });
271
- const userStorePath = shellPaths.resolveUserPath('tui', 'auth-users.json');
272
- const bootstrapCredentialPath = shellPaths.resolveUserPath('tui', 'auth-bootstrap.txt');
273
- const operatorTokenPath = join(runtime.homeDirectory, '.goodvibes', 'daemon', 'operator-tokens.json');
274
- return {
275
- userStorePath,
276
- userStorePresent: existsSync(userStorePath),
277
- bootstrapCredentialPath,
278
- bootstrapCredentialPresent: existsSync(bootstrapCredentialPath),
279
- operatorTokenPath,
280
- operatorTokenPresent: existsSync(operatorTokenPath),
281
- };
282
- }
283
-
284
- export async function runNonInteractiveAgent(runtime: CliCommandRuntime): Promise<number> {
285
- const prompt = runtime.cli.flags.prompt ?? runtime.cli.positionals.join(' ').trim();
286
- if (!prompt) {
287
- console.error('Usage: goodvibes run|exec [prompt]');
288
- return 2;
289
- }
290
-
291
- const outputFormat = runtime.cli.flags.outputFormat;
292
- const ctx = await bootstrapRuntime(process.stdout, {
293
- configManager: runtime.configManager,
294
- workingDir: runtime.workingDirectory,
295
- homeDirectory: runtime.homeDirectory,
296
- });
297
-
298
- const events: TurnEvent[] = [];
299
- let finalResponse = '';
300
- let finalError = '';
301
- let finalStopReason = '';
302
- let exitCode = 0;
303
-
304
- const done = new Promise<void>((resolve) => {
305
- const unsubs = [
306
- ctx.runtimeBus.on<Extract<TurnEvent, { type: 'STREAM_DELTA' }>>('STREAM_DELTA', ({ payload }) => {
307
- events.push(payload);
308
- if (outputFormat === 'stream-json') {
309
- process.stdout.write(JSON.stringify({ type: payload.type, content: payload.content, accumulated: payload.accumulated }) + '\n');
310
- }
311
- }),
312
- ctx.runtimeBus.on<Extract<TurnEvent, { type: 'TURN_COMPLETED' }>>('TURN_COMPLETED', ({ payload }) => {
313
- events.push(payload);
314
- finalResponse = payload.response;
315
- finalStopReason = payload.stopReason;
316
- for (const unsub of unsubs) unsub();
317
- resolve();
318
- }),
319
- ctx.runtimeBus.on<Extract<TurnEvent, { type: 'TURN_ERROR' }>>('TURN_ERROR', ({ payload }) => {
320
- events.push(payload);
321
- finalError = payload.error;
322
- finalStopReason = payload.stopReason;
323
- exitCode = 1;
324
- for (const unsub of unsubs) unsub();
325
- resolve();
326
- }),
327
- ctx.runtimeBus.on<Extract<TurnEvent, { type: 'TURN_CANCEL' }>>('TURN_CANCEL', ({ payload }) => {
328
- events.push(payload);
329
- finalError = payload.reason ?? 'cancelled';
330
- finalStopReason = payload.stopReason;
331
- exitCode = 130;
332
- for (const unsub of unsubs) unsub();
333
- resolve();
334
- }),
335
- ];
336
- });
337
-
338
- try {
339
- await ctx.orchestrator.handleUserInput(prompt);
340
- await done;
341
- if (outputFormat === 'json') {
342
- process.stdout.write(JSON.stringify({
343
- ok: exitCode === 0,
344
- response: finalResponse,
345
- error: finalError || undefined,
346
- stopReason: finalStopReason,
347
- sessionId: ctx.runtime.sessionId,
348
- model: ctx.runtime.model,
349
- provider: ctx.runtime.provider,
350
- events: events.length,
351
- }, null, 2) + '\n');
352
- } else if (outputFormat !== 'stream-json') {
353
- process.stdout.write((exitCode === 0 ? finalResponse : finalError) + '\n');
354
- } else {
355
- process.stdout.write(JSON.stringify({
356
- type: exitCode === 0 ? 'TURN_COMPLETED' : 'TURN_ERROR',
357
- ok: exitCode === 0,
358
- response: finalResponse,
359
- error: finalError || undefined,
360
- stopReason: finalStopReason,
361
- }) + '\n');
362
- }
363
- } finally {
364
- const snapshot = ctx.conversation.toJSON() as Parameters<typeof ctx.shutdown>[0];
365
- await ctx.shutdown(snapshot);
366
- }
367
- return exitCode;
368
- }
369
-
370
72
  async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
371
73
  return await withRuntimeServices(runtime, async (services) => {
372
74
  const [sub = 'list', ...rest] = runtime.cli.commandArgs;
package/src/cli/parser.ts CHANGED
@@ -174,6 +174,7 @@ export function parseGoodVibesCli(
174
174
  const commandArgs: string[] = [];
175
175
  const positionals: string[] = [];
176
176
  const errors: string[] = [];
177
+ const warnings: string[] = [];
177
178
  let sawCommand = false;
178
179
  let passthrough = false;
179
180
 
@@ -292,6 +293,9 @@ export function parseGoodVibesCli(
292
293
  const consumed = getValue(argv, index, inlineValue, name, errors);
293
294
  index = consumed.nextIndex;
294
295
  flags = withFlag(flags, 'outputFormat', normalizeOutputFormat(consumed.value, name, errors));
296
+ if (name === '--output-format') {
297
+ warnings.push('--output-format is deprecated; use --output (or -o) instead.');
298
+ }
295
299
  continue;
296
300
  }
297
301
  if (name === '--config' || name === '-c') {
@@ -353,6 +357,18 @@ export function parseGoodVibesCli(
353
357
  errors.push(`Unknown command: ${rawCommand}`);
354
358
  }
355
359
 
360
+ // Session lifecycle conflict detection — only one of --continue / --resume / --fork may be used.
361
+ const sessionLifecycleFlags = [
362
+ flags.continueLast ? '--continue' : undefined,
363
+ flags.resume !== undefined ? '--resume' : undefined,
364
+ flags.fork !== undefined ? '--fork' : undefined,
365
+ ].filter((f): f is string => f !== undefined);
366
+ if (sessionLifecycleFlags.length > 1) {
367
+ errors.push(
368
+ `Conflicting session lifecycle flags: ${sessionLifecycleFlags.join(' and ')}. Use only one of --continue, --resume, or --fork.`,
369
+ );
370
+ }
371
+
356
372
  return {
357
373
  binary,
358
374
  command,
@@ -361,5 +377,6 @@ export function parseGoodVibesCli(
361
377
  positionals,
362
378
  flags,
363
379
  errors,
380
+ warnings,
364
381
  };
365
382
  }
@@ -18,7 +18,7 @@ import {
18
18
  probeTcp,
19
19
  readAuthPaths,
20
20
  yesNo,
21
- } from './management.ts';
21
+ } from './management-utils.ts';
22
22
 
23
23
  export const SURFACE_CONFIGS = [
24
24
  ['slack', 'Slack', ['surfaces.slack.signingSecret', 'surfaces.slack.botToken']],
package/src/cli/types.ts CHANGED
@@ -68,6 +68,8 @@ export interface GoodVibesCliParseResult {
68
68
  readonly positionals: readonly string[];
69
69
  readonly flags: GoodVibesCliFlags;
70
70
  readonly errors: readonly string[];
71
+ /** Deprecation and soft-warning messages (non-fatal). Callers should surface these to users. */
72
+ readonly warnings: readonly string[];
71
73
  }
72
74
 
73
75
  export interface CliCommandRuntime {
@@ -117,6 +117,8 @@ const KNOWN_DYNAMIC_KEYS = [
117
117
  /^featureFlags(?:\.|$)/,
118
118
  /^notifications\.webhookUrls$/,
119
119
  /^wrfc\.gates$/,
120
+ // TUI-bridged setting awaiting SDK schema registration (handoff Item 5b)
121
+ /^tts\.speed$/,
120
122
  ];
121
123
 
122
124
  export const GOODVIBES_ALLOWED_WRITE_ROOTS = ['tui/', 'daemon/'] as const;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Auto-compaction helper — TASK-058.
3
+ *
4
+ * Evaluates whether auto-compact should run after a turn completes and, if so,
5
+ * triggers compaction and posts an honest transcript notice.
6
+ *
7
+ * behavior.autoCompactThreshold: SDK schema range [10, 100], default 80.
8
+ * Auto-compact is active whenever the threshold is in its valid range (>0).
9
+ * The display/suggestion path (hint + meter) is always active regardless.
10
+ */
11
+
12
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
+ import type { ConversationManager } from './conversation';
14
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
15
+ import type { SystemMessageRouter } from './system-message-router';
16
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
17
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
+ import { getLastCompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
19
+ import type { CompactionContext } from '@pellux/goodvibes-sdk/platform/core';
20
+ import { buildCompactionPreview, buildCompactionAfterNotice } from '../renderer/compaction-preview.ts';
21
+
22
+ export interface AutoCompactDeps {
23
+ readonly configManager: Pick<ConfigManager, 'get'>;
24
+ readonly conversation: ConversationManager;
25
+ readonly providerRegistry: ProviderRegistry;
26
+ readonly systemMessageRouter: SystemMessageRouter;
27
+ readonly model: string;
28
+ readonly provider: string;
29
+ readonly lastInputTokens: number;
30
+ readonly contextWindow: number;
31
+ }
32
+
33
+ /**
34
+ * Run after each TURN_COMPLETED event.
35
+ *
36
+ * Reads behavior.autoCompactThreshold from config (SDK default: 80, range [10, 100]).
37
+ * When usage is at or above the threshold, compacts the conversation and posts
38
+ * an honest transcript notice so the user understands any summary discontinuity.
39
+ *
40
+ * This function is intentionally non-throwing; failures are logged and
41
+ * surfaced via the system message router.
42
+ */
43
+ export async function maybeAutoCompact(deps: AutoCompactDeps): Promise<void> {
44
+ // SDK schema default is 80; valid range is [10, 100]. The ?? 0 fallback is a
45
+ // defensive guard for missing/null values only — not a normal operating state.
46
+ const rawThreshold = Number(deps.configManager.get('behavior.autoCompactThreshold') ?? 0);
47
+ const thresholdPct = Number.isFinite(rawThreshold) ? rawThreshold : 0;
48
+
49
+ // Defensive guard: skip only when threshold is missing/non-positive (real config defaults to 80).
50
+ if (thresholdPct <= 0 || deps.contextWindow <= 0) return;
51
+
52
+ const usagePct = Math.min(100, Math.round((Math.max(0, deps.lastInputTokens) / deps.contextWindow) * 100));
53
+ if (usagePct < thresholdPct) return;
54
+
55
+ try {
56
+ logger.debug('auto-compact triggered', { usagePct, thresholdPct });
57
+ // Pre-compact preview — uses buildCompactionPreview for an honest estimate.
58
+ const messages = deps.conversation.getMessagesForLLM();
59
+ const sessionMemoryStore = deps.conversation.getSessionMemoryStore();
60
+ const sessionMemories = sessionMemoryStore?.list() ?? [];
61
+ const pinnedMemoryCount = sessionMemories.length;
62
+ const preview = buildCompactionPreview({
63
+ messages,
64
+ contextWindow: deps.contextWindow,
65
+ pinnedMemoryCount,
66
+ trigger: 'auto',
67
+ });
68
+ deps.systemMessageRouter.routeSystemMessage(preview, 'high');
69
+ const eventBefore = getLastCompactionEvent();
70
+ const compactionCtx: CompactionContext = {
71
+ messages,
72
+ sessionMemories,
73
+ agents: [],
74
+ wrfcChains: [],
75
+ activePlan: null,
76
+ lineageEntries: [],
77
+ compactionCount: 0,
78
+ contextWindow: deps.contextWindow,
79
+ trigger: 'auto',
80
+ extractionModelId: deps.model,
81
+ extractionProvider: deps.provider,
82
+ };
83
+ await deps.conversation.compact(
84
+ deps.providerRegistry,
85
+ deps.model,
86
+ 'auto',
87
+ deps.provider,
88
+ compactionCtx,
89
+ );
90
+ // Post-compact notice using real CompactionEvent figures.
91
+ const eventAfter = getLastCompactionEvent();
92
+ if (eventAfter !== null && eventAfter !== eventBefore) {
93
+ deps.systemMessageRouter.routeSystemMessage(
94
+ buildCompactionAfterNotice({ event: eventAfter, pinnedMemoryCount }),
95
+ 'low',
96
+ );
97
+ } else {
98
+ deps.systemMessageRouter.routeSystemMessage(
99
+ '[Context] Auto-compact complete — older turns summarised. Use /compact to compact again manually.',
100
+ 'low',
101
+ );
102
+ }
103
+ } catch (err) {
104
+ logger.error('auto-compact failed', { error: summarizeError(err) });
105
+ deps.systemMessageRouter.routeSystemMessage(
106
+ `[Context] Auto-compact failed: ${summarizeError(err)}. Use /compact to try manually.`,
107
+ 'high',
108
+ );
109
+ }
110
+ }
@@ -11,10 +11,13 @@ import { LAYOUT } from '../renderer/layout.ts';
11
11
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
12
12
  import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
13
13
  import { GLYPHS } from '../renderer/ui-primitives.ts';
14
- import type { BlockMeta, ConversationMessageSnapshot } from './conversation';
14
+ import type { BlockMeta } from './conversation-types.ts';
15
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
15
16
  import { parseDiffForApply } from '@pellux/goodvibes-sdk/platform/core';
16
17
  import { extractUserDisplayText } from '@pellux/goodvibes-sdk/platform/core';
17
- import type { SystemMessageKind } from './system-message-router.ts';
18
+ // SystemMessageKind imported from runtime directly to avoid cycle:
19
+ // conversation-rendering.ts → system-message-router.ts → conversation.ts → conversation-rendering.ts
20
+ import type { SystemMessageKind } from '@/runtime/index.ts';
18
21
 
19
22
  const T = DARK_THEME;
20
23
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * conversation-types.ts — shared TUI extension types for ConversationManager.
3
+ *
4
+ * Extracted from conversation.ts so that conversation-rendering.ts can import
5
+ * BlockMeta without creating a circular dependency:
6
+ * conversation.ts ↔ conversation-rendering.ts
7
+ *
8
+ * Both files import from this module; conversation.ts re-exports BlockMeta for
9
+ * backward compatibility of all existing importers.
10
+ */
11
+
12
+ import type { BlockMeta as SdkBlockMeta } from '@pellux/goodvibes-sdk/platform/core';
13
+
14
+ /** TUI extends the SDK BlockMeta with rendering position fields. */
15
+ export interface BlockMeta extends SdkBlockMeta {
16
+ /** Index of this block (increments per renderable block). */
17
+ blockIndex: number;
18
+ /** First rendered line index in the history buffer. */
19
+ startLine: number;
20
+ /** Number of rendered lines (when not collapsed). */
21
+ lineCount: number;
22
+ /** Stable key for collapse state persistence across rebuilds (e.g. msg_N). */
23
+ collapseKey: string;
24
+ }
@@ -5,11 +5,14 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
5
5
  import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
6
6
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
8
- import type { SystemMessageKind } from './system-message-router.ts';
8
+ // SystemMessageKind imported from runtime directly to avoid cycle:
9
+ // conversation.ts → system-message-router.ts → conversation.ts
10
+ import type { SystemMessageKind } from '@/runtime/index.ts';
9
11
  import {
10
12
  ConversationManager as SdkConversationManager,
11
13
  type BlockMeta as SdkBlockMeta,
12
14
  } from '@pellux/goodvibes-sdk/platform/core';
15
+ import type { BlockMeta } from './conversation-types.ts';
13
16
  import {
14
17
  addConversationSplashScreen,
15
18
  appendConversationMessages,
@@ -38,17 +41,9 @@ export type {
38
41
 
39
42
  export type { SdkBlockMeta };
40
43
 
41
- /** TUI extends the SDK BlockMeta with rendering position fields. */
42
- export interface BlockMeta extends SdkBlockMeta {
43
- /** Index of this block (increments per renderable block). */
44
- blockIndex: number;
45
- /** First rendered line index in the history buffer. */
46
- startLine: number;
47
- /** Number of rendered lines (when not collapsed). */
48
- lineCount: number;
49
- /** Stable key for collapse state persistence across rebuilds (e.g. msg_N). */
50
- collapseKey: string;
51
- }
44
+ // BlockMeta is defined in ./conversation-types.ts to avoid a circular dep
45
+ // with conversation-rendering.ts; re-exported here for backward compatibility.
46
+ export type { BlockMeta };
52
47
 
53
48
  // Import internal types needed for rendering helpers
54
49
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';