@pellux/goodvibes-tui 0.20.3 → 0.22.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 +50 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +4 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +658 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +31 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +14 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +32 -1
- package/src/input/commands/control-room-runtime.ts +10 -10
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -4
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +128 -12
- package/src/input/handler-modal-token-routes.ts +22 -5
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +73 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +6 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +378 -0
- package/src/input/settings-modal-mutations.ts +157 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +236 -232
- package/src/main.ts +93 -85
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +5 -1
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/session-maintenance.ts +66 -15
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +118 -13
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +90 -10
- package/src/renderer/shell-surface.ts +10 -0
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +18 -0
- package/src/runtime/bootstrap-core.ts +145 -13
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +9 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +27 -1
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/panels/knowledge-panel.ts +0 -345
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
2
|
+
import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
|
|
3
|
+
import { formatUserFacingErrorLine } from './format-user-error.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
|
|
7
|
+
* The object is mutated in place by event handlers; callers declare it before
|
|
8
|
+
* render() and pass it in, so render() can read the fields without copying.
|
|
9
|
+
*/
|
|
10
|
+
export interface StreamMetrics {
|
|
11
|
+
/** Epoch ms when the most recent STREAM_START fired; 0 when idle. */
|
|
12
|
+
startTime: number;
|
|
13
|
+
/** Number of STREAM_DELTA events received since the last STREAM_START. */
|
|
14
|
+
deltaCount: number;
|
|
15
|
+
/** Computed tokens-per-second at the last STREAM_DELTA; 0 when idle. */
|
|
16
|
+
tokenSpeed: number;
|
|
17
|
+
/** Elapsed ms from STREAM_START to first STREAM_DELTA (time-to-first-token). */
|
|
18
|
+
ttftMs: number | undefined;
|
|
19
|
+
/** Whether TTFT has been recorded for the current turn. */
|
|
20
|
+
ttftRecorded: boolean;
|
|
21
|
+
/** Epoch ms when the most recent TOOL_EXECUTING event fired; undefined when idle. */
|
|
22
|
+
activeToolStartedAtMs: number | undefined;
|
|
23
|
+
/** Name of the currently executing tool; cleared when execution completes. */
|
|
24
|
+
activeToolName: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minimal orchestrator surface required for stream token-speed calculation. */
|
|
28
|
+
interface StreamOrchestrator {
|
|
29
|
+
readonly streamingOutputTokens: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal provider surface required for the stream stall watchdog. */
|
|
33
|
+
interface StreamProviderRegistry {
|
|
34
|
+
getCurrentModel(): { readonly provider: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Minimal system-message surface required for user-visible notifications. */
|
|
38
|
+
interface StreamSystemMessageRouter {
|
|
39
|
+
high(message: string): void;
|
|
40
|
+
low(message: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WireStreamEventMetricsOptions {
|
|
44
|
+
/** The UI runtime event bus (turns + tools sub-buses). */
|
|
45
|
+
readonly events: UiRuntimeEvents;
|
|
46
|
+
/** Orchestrator reference used to read real output token counts. */
|
|
47
|
+
readonly orchestrator: StreamOrchestrator;
|
|
48
|
+
/** Provider registry used by the stall watchdog to name the current provider. */
|
|
49
|
+
readonly providerRegistry: StreamProviderRegistry;
|
|
50
|
+
/** System message router for turn-error and stall notifications. */
|
|
51
|
+
readonly systemMessageRouter: StreamSystemMessageRouter;
|
|
52
|
+
/** Trigger a UI repaint after a state mutation. */
|
|
53
|
+
readonly render: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* Caller-owned metrics object to mutate in place. Declared before render()
|
|
56
|
+
* so the render closure can read it without a forward-reference issue.
|
|
57
|
+
*/
|
|
58
|
+
readonly metrics: StreamMetrics;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
|
|
63
|
+
* and install the stream-stall watchdog. The caller owns the metrics object
|
|
64
|
+
* and declares it before render() so both the render closure and the returned
|
|
65
|
+
* event handlers share the same reference.
|
|
66
|
+
*
|
|
67
|
+
* Returns an array of unsubscribe functions; push them into the parent unsubs
|
|
68
|
+
* array so they are cleaned up on exit.
|
|
69
|
+
*
|
|
70
|
+
* Responsibilities:
|
|
71
|
+
* - Track stream start time, delta count, token speed, and TTFT
|
|
72
|
+
* - Track the currently executing tool name and start time
|
|
73
|
+
* - Display TURN_ERROR messages via systemMessageRouter
|
|
74
|
+
* - Emit a stall hint when STREAM_START has no delta within the watchdog threshold
|
|
75
|
+
*/
|
|
76
|
+
export function wireStreamEventMetrics(
|
|
77
|
+
options: WireStreamEventMetricsOptions,
|
|
78
|
+
): ReadonlyArray<() => void> {
|
|
79
|
+
const { events, metrics, orchestrator, providerRegistry, systemMessageRouter, render } = options;
|
|
80
|
+
|
|
81
|
+
const unsubs: Array<() => void> = [];
|
|
82
|
+
|
|
83
|
+
unsubs.push(events.turns.on('STREAM_START', () => {
|
|
84
|
+
metrics.startTime = Date.now();
|
|
85
|
+
metrics.deltaCount = 0;
|
|
86
|
+
metrics.tokenSpeed = 0;
|
|
87
|
+
metrics.ttftMs = undefined;
|
|
88
|
+
metrics.ttftRecorded = false;
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
unsubs.push(events.turns.on('STREAM_DELTA', () => {
|
|
92
|
+
metrics.deltaCount++;
|
|
93
|
+
const elapsed = (Date.now() - metrics.startTime) / 1000;
|
|
94
|
+
// Record TTFT on the first delta of each turn.
|
|
95
|
+
if (!metrics.ttftRecorded) {
|
|
96
|
+
metrics.ttftMs = Date.now() - metrics.startTime;
|
|
97
|
+
metrics.ttftRecorded = true;
|
|
98
|
+
}
|
|
99
|
+
// Use real output token count for accurate tok/s; fall back to delta count.
|
|
100
|
+
const tokenCount = orchestrator.streamingOutputTokens > 0
|
|
101
|
+
? orchestrator.streamingOutputTokens
|
|
102
|
+
: metrics.deltaCount;
|
|
103
|
+
metrics.tokenSpeed = elapsed > 0 ? tokenCount / elapsed : 0;
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
unsubs.push(events.turns.on('TURN_ERROR', (event) => {
|
|
107
|
+
const errVal: string = event.error;
|
|
108
|
+
const formatted = formatUserFacingErrorLine(errVal);
|
|
109
|
+
systemMessageRouter.high(`[Error] ${formatted}`);
|
|
110
|
+
render();
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// --- Stream stall watchdog: emit one low hint if STREAM_START has no delta within 30s ---
|
|
114
|
+
const stallWatchdog = createStreamStallWatchdog({
|
|
115
|
+
events: events.turns,
|
|
116
|
+
onStall: (providerName) => {
|
|
117
|
+
systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
|
|
118
|
+
render();
|
|
119
|
+
},
|
|
120
|
+
getProviderName: () => providerRegistry.getCurrentModel().provider,
|
|
121
|
+
// thresholdMs uses the default 30 000
|
|
122
|
+
});
|
|
123
|
+
unsubs.push(() => stallWatchdog.dispose());
|
|
124
|
+
|
|
125
|
+
unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
|
|
126
|
+
metrics.activeToolStartedAtMs = ev.startedAt;
|
|
127
|
+
metrics.activeToolName = ev.tool;
|
|
128
|
+
render();
|
|
129
|
+
}));
|
|
130
|
+
unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
|
|
131
|
+
metrics.activeToolStartedAtMs = undefined;
|
|
132
|
+
metrics.activeToolName = undefined;
|
|
133
|
+
}));
|
|
134
|
+
unsubs.push(events.tools.on('TOOL_FAILED', () => {
|
|
135
|
+
metrics.activeToolStartedAtMs = undefined;
|
|
136
|
+
metrics.activeToolName = undefined;
|
|
137
|
+
}));
|
|
138
|
+
unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
|
|
139
|
+
metrics.activeToolStartedAtMs = undefined;
|
|
140
|
+
metrics.activeToolName = undefined;
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return unsubs;
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StreamStallWatchdog — detects STREAM_START events where no STREAM_DELTA
|
|
3
|
+
* arrives within a configurable threshold, and emits a single low-priority
|
|
4
|
+
* hint to the user.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - Arm on STREAM_START: set a timeout for STALL_THRESHOLD_MS.
|
|
8
|
+
* - Disarm on STREAM_DELTA: clear the pending timeout (stream is alive).
|
|
9
|
+
* - Disarm on STREAM_END / TURN_COMPLETED / TURN_ERROR / TURN_CANCEL:
|
|
10
|
+
* clear the timeout (turn finished normally or with error).
|
|
11
|
+
* - If the timeout fires: emit ONE hint, do NOT repeat until the next turn.
|
|
12
|
+
* - Re-arm only on the next STREAM_START (next turn).
|
|
13
|
+
* - dispose(): clears all subscriptions and any pending timeout.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Milliseconds of silence after STREAM_START before emitting the hint. */
|
|
19
|
+
export const STALL_THRESHOLD_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/** Events surface subset the watchdog needs. */
|
|
22
|
+
export interface WatchdogTurnEvents {
|
|
23
|
+
on(event: 'STREAM_START', handler: () => void): () => void;
|
|
24
|
+
on(event: 'STREAM_DELTA', handler: () => void): () => void;
|
|
25
|
+
on(event: 'STREAM_END', handler: () => void): () => void;
|
|
26
|
+
on(event: 'TURN_COMPLETED', handler: () => void): () => void;
|
|
27
|
+
on(event: 'TURN_ERROR', handler: () => void): () => void;
|
|
28
|
+
on(event: 'TURN_CANCEL', handler: () => void): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StreamStallWatchdogOptions {
|
|
32
|
+
/** The turns event surface to subscribe on. */
|
|
33
|
+
events: WatchdogTurnEvents;
|
|
34
|
+
/**
|
|
35
|
+
* Called once per turn when the stall threshold is exceeded.
|
|
36
|
+
* Receives the provider display name for the hint message.
|
|
37
|
+
*/
|
|
38
|
+
onStall: (providerName: string) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Provides the current provider display name at the moment the hint fires.
|
|
41
|
+
* Optional — defaults to 'provider' when not supplied.
|
|
42
|
+
*/
|
|
43
|
+
getProviderName?: () => string;
|
|
44
|
+
/**
|
|
45
|
+
* Stall threshold in ms. Defaults to STALL_THRESHOLD_MS (30 000).
|
|
46
|
+
* Exposed for unit tests.
|
|
47
|
+
*/
|
|
48
|
+
thresholdMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StreamStallWatchdog {
|
|
52
|
+
/** Tear down all subscriptions and cancel any pending timeout. */
|
|
53
|
+
dispose(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates and starts a StreamStallWatchdog.
|
|
58
|
+
*
|
|
59
|
+
* Returns a dispose function; add it to the `unsubs` array alongside other
|
|
60
|
+
* event subscriptions so it is cleaned up on exit.
|
|
61
|
+
*/
|
|
62
|
+
export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): StreamStallWatchdog {
|
|
63
|
+
const { events, onStall, getProviderName, thresholdMs = STALL_THRESHOLD_MS } = opts;
|
|
64
|
+
|
|
65
|
+
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
let hintFiredForTurn = false;
|
|
67
|
+
|
|
68
|
+
function arm(): void {
|
|
69
|
+
disarm();
|
|
70
|
+
hintFiredForTurn = false;
|
|
71
|
+
stallTimer = setTimeout(() => {
|
|
72
|
+
stallTimer = null;
|
|
73
|
+
if (!hintFiredForTurn) {
|
|
74
|
+
hintFiredForTurn = true;
|
|
75
|
+
const provider = getProviderName ? getProviderName() : 'provider';
|
|
76
|
+
onStall(provider);
|
|
77
|
+
}
|
|
78
|
+
}, thresholdMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function disarm(): void {
|
|
82
|
+
if (stallTimer !== null) {
|
|
83
|
+
clearTimeout(stallTimer);
|
|
84
|
+
stallTimer = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unsubs: Array<() => void> = [
|
|
89
|
+
events.on('STREAM_START', arm),
|
|
90
|
+
events.on('STREAM_DELTA', disarm),
|
|
91
|
+
events.on('STREAM_END', disarm),
|
|
92
|
+
events.on('TURN_COMPLETED', disarm),
|
|
93
|
+
events.on('TURN_ERROR', disarm),
|
|
94
|
+
events.on('TURN_CANCEL', disarm),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
dispose(): void {
|
|
99
|
+
disarm();
|
|
100
|
+
for (const unsub of unsubs) unsub();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -81,6 +81,8 @@ export class SystemMessageRouter {
|
|
|
81
81
|
*
|
|
82
82
|
* @param message - Message text.
|
|
83
83
|
* @param priority - 'high' | 'low'.
|
|
84
|
+
* @param kind - Classification kind ('system' | 'wrfc' | 'operational');
|
|
85
|
+
* used to resolve routing target and conversation navigability.
|
|
84
86
|
*/
|
|
85
87
|
routeTypedSystemMessage(
|
|
86
88
|
message: string,
|
|
@@ -93,7 +95,9 @@ export class SystemMessageRouter {
|
|
|
93
95
|
this.panel?.push(message, priority);
|
|
94
96
|
}
|
|
95
97
|
if (delivery.toConversation) {
|
|
96
|
-
|
|
98
|
+
// addTypedSystemMessage threads the kind into the conversation so the
|
|
99
|
+
// renderer can use kind-based navigability instead of substring matching.
|
|
100
|
+
this.conversation.addTypedSystemMessage(message, kind);
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
@@ -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);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// cost-utils — canonical pricing source for token cost calculations (consumed by CostTrackerPanel and share-runtime)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface ModelPricing {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
11
|
+
// Free tier
|
|
12
|
+
'openrouter/free': { input: 0, output: 0 },
|
|
13
|
+
|
|
14
|
+
// InceptionLabs
|
|
15
|
+
'mercury-2': { input: 0.50, output: 1.50 },
|
|
16
|
+
'mercury-edit': { input: 0.50, output: 1.50 },
|
|
17
|
+
|
|
18
|
+
// OpenAI
|
|
19
|
+
'gpt-5.4': { input: 5, output: 15 },
|
|
20
|
+
'gpt-5.3-chat-latest': { input: 3, output: 10 },
|
|
21
|
+
'gpt-5-mini': { input: 0.15, output: 0.60 },
|
|
22
|
+
'gpt-5-nano': { input: 0.05, output: 0.20 },
|
|
23
|
+
'gpt-oss-120b': { input: 0, output: 0 },
|
|
24
|
+
|
|
25
|
+
// Anthropic
|
|
26
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
27
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
28
|
+
'claude-haiku-4-5': { input: 0.80, output: 4 },
|
|
29
|
+
|
|
30
|
+
// Google
|
|
31
|
+
'gemini-3.1-pro': { input: 1.25, output: 5 },
|
|
32
|
+
'gemini-3-flash': { input: 0.075, output: 0.30 },
|
|
33
|
+
'gemini-3.1-flash-lite': { input: 0.02, output: 0.10 },
|
|
34
|
+
'gemini-2.5-pro': { input: 1.25, output: 5 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* getPricing — resolve USD-per-1M-token pricing for a model ID.
|
|
39
|
+
* Exact match first; then prefix/substring; falls back to zero.
|
|
40
|
+
*/
|
|
41
|
+
export function getPricing(modelId: string): ModelPricing {
|
|
42
|
+
if (MODEL_PRICING[modelId]) return MODEL_PRICING[modelId]!;
|
|
43
|
+
if (modelId.endsWith(':free')) return { input: 0, output: 0 };
|
|
44
|
+
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
45
|
+
if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
|
|
46
|
+
}
|
|
47
|
+
return { input: 0, output: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* calcSessionCost — compute total session cost in USD given raw token counters
|
|
52
|
+
* and the active model ID. Same formula used by CostTrackerPanel.onTurnComplete.
|
|
53
|
+
*
|
|
54
|
+
* inputTokens — cumulative input tokens
|
|
55
|
+
* outputTokens — cumulative output tokens
|
|
56
|
+
* cacheRead — cumulative cache-read tokens
|
|
57
|
+
* cacheWrite — cumulative cache-write tokens
|
|
58
|
+
* modelId — registry model identifier
|
|
59
|
+
*/
|
|
60
|
+
export function calcSessionCost(
|
|
61
|
+
inputTokens: number,
|
|
62
|
+
outputTokens: number,
|
|
63
|
+
cacheRead: number,
|
|
64
|
+
cacheWrite: number,
|
|
65
|
+
modelId: string,
|
|
66
|
+
): number {
|
|
67
|
+
const pricing = getPricing(modelId);
|
|
68
|
+
// Cache reads/writes count toward billable input side (same convention as panel)
|
|
69
|
+
const billableInput = inputTokens + cacheRead + cacheWrite;
|
|
70
|
+
return (billableInput * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
71
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// gist-uploader — upload export content to a GitHub Gist
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Architecture: UploadTarget interface with a single GistUploadTarget
|
|
6
|
+
// implementation. Future targets (HTTP PUT, Pastebin, etc.) implement
|
|
7
|
+
// UploadTarget without changing the caller in share-runtime.
|
|
8
|
+
//
|
|
9
|
+
// Token resolution:
|
|
10
|
+
// 1. serviceRegistry.resolveAuth('github') — standard service registry path
|
|
11
|
+
// (configured via .goodvibes/tui/services.json with tokenKey: GITHUB_TOKEN)
|
|
12
|
+
// 2. process.env.GITHUB_TOKEN fallback
|
|
13
|
+
// 3. No token → honest guidance; no upload.
|
|
14
|
+
//
|
|
15
|
+
// Privacy: Gist is created as secret=true (unlisted, not private — anyone with
|
|
16
|
+
// the URL can view it).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type UploadResult =
|
|
20
|
+
| { ok: true; url: string }
|
|
21
|
+
| { ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* UploadTarget — interface for pluggable export upload backends.
|
|
25
|
+
* Future HTTP PUT / other targets implement this.
|
|
26
|
+
*/
|
|
27
|
+
export interface UploadTarget {
|
|
28
|
+
upload(content: string, filename: string): Promise<UploadResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GistUploaderOptions {
|
|
32
|
+
/**
|
|
33
|
+
* GitHub PAT with `gist` scope. If not provided the uploader will try
|
|
34
|
+
* process.env.GITHUB_TOKEN then return an error with guidance.
|
|
35
|
+
*/
|
|
36
|
+
token?: string;
|
|
37
|
+
/** Description shown on the Gist page. Defaults to filename. */
|
|
38
|
+
description?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* resolveGithubToken — try auth header map then env var.
|
|
43
|
+
* Returns undefined when no token is available.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveGithubToken(
|
|
46
|
+
authHeaders: Record<string, string> | null | undefined,
|
|
47
|
+
): string | undefined {
|
|
48
|
+
if (authHeaders) {
|
|
49
|
+
// Service registry returns { Authorization: 'Bearer <token>' } for bearer type
|
|
50
|
+
const authHeader = authHeaders['Authorization'] ?? authHeaders['authorization'];
|
|
51
|
+
if (authHeader) {
|
|
52
|
+
const match = /^Bearer (.+)$/.exec(authHeader);
|
|
53
|
+
if (match?.[1]) return match[1];
|
|
54
|
+
}
|
|
55
|
+
// Fallback: raw token value under any key that contains 'token'
|
|
56
|
+
for (const [key, val] of Object.entries(authHeaders)) {
|
|
57
|
+
if (key.toLowerCase().includes('token') && val) return val;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Env var fallback
|
|
61
|
+
const envToken = process.env['GITHUB_TOKEN'];
|
|
62
|
+
return envToken || undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GistUploadTarget — uploads content to a secret (unlisted) GitHub Gist.
|
|
67
|
+
*/
|
|
68
|
+
export class GistUploadTarget implements UploadTarget {
|
|
69
|
+
private readonly token: string;
|
|
70
|
+
private readonly description: string;
|
|
71
|
+
|
|
72
|
+
constructor(token: string, description?: string) {
|
|
73
|
+
this.token = token;
|
|
74
|
+
this.description = description ?? 'GoodVibes session export';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async upload(content: string, filename: string): Promise<UploadResult> {
|
|
78
|
+
const body = JSON.stringify({
|
|
79
|
+
description: this.description,
|
|
80
|
+
public: false, // secret gist: unlisted, not private
|
|
81
|
+
files: {
|
|
82
|
+
[filename]: { content },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let response: Response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetch('https://api.github.com/gists', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
'Accept': 'application/vnd.github+json',
|
|
92
|
+
'Authorization': `Bearer ${this.token}`,
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
95
|
+
},
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
} catch (fetchErr: unknown) {
|
|
99
|
+
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
100
|
+
return { ok: false, error: `Network error: ${msg}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const text = await response.text().catch(() => '');
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: `GitHub API error ${response.status}: ${text.slice(0, 200)}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let json: unknown;
|
|
112
|
+
try {
|
|
113
|
+
json = await response.json();
|
|
114
|
+
} catch {
|
|
115
|
+
return { ok: false, error: 'GitHub API returned non-JSON response' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const gistUrl = (json as Record<string, unknown>)['html_url'];
|
|
119
|
+
if (typeof gistUrl !== 'string') {
|
|
120
|
+
return { ok: false, error: 'GitHub API response missing html_url field' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ok: true, url: gistUrl };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* noTokenGuidance — message printed when no GitHub PAT is found.
|
|
129
|
+
*/
|
|
130
|
+
export const NO_TOKEN_GUIDANCE = [
|
|
131
|
+
'No GitHub token found for --upload.',
|
|
132
|
+
'To enable Gist upload, configure a GitHub service entry:',
|
|
133
|
+
' /services import .goodvibes/tui/services.json (if already configured)',
|
|
134
|
+
'Or set the GITHUB_TOKEN environment variable to a PAT with the `gist` scope.',
|
|
135
|
+
'Token is sent to api.github.com only. Gists are secret (unlisted, not private).',
|
|
136
|
+
].join('\n');
|
|
@@ -118,8 +118,19 @@ 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;
|
|
124
|
+
/**
|
|
125
|
+
* Open the LocalAuthPanel in masked-password-entry mode for the given
|
|
126
|
+
* operation and username. The panel captures keystrokes into a private
|
|
127
|
+
* buffer; no plaintext password is ever stored in input history, transcript,
|
|
128
|
+
* logs, or recovery files.
|
|
129
|
+
*/
|
|
130
|
+
openLocalAuthMaskedEntry?: (
|
|
131
|
+
kind: 'add-user' | 'rotate-password',
|
|
132
|
+
username: string,
|
|
133
|
+
) => void;
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
export interface CommandSessionServices {
|
|
@@ -233,8 +244,28 @@ export class CommandRegistry {
|
|
|
233
244
|
private commands = new Map<string, SlashCommand>();
|
|
234
245
|
private aliasIndex = new Map<string, SlashCommand>();
|
|
235
246
|
|
|
236
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Register a command. Also indexes all aliases for O(1) lookup.
|
|
249
|
+
* Throws if the primary name or any alias collides with an existing
|
|
250
|
+
* registration — silent last-write-wins previously shadowed whole
|
|
251
|
+
* commands, so collisions must fail fast at startup (and are caught
|
|
252
|
+
* statically by the alias lint test).
|
|
253
|
+
*/
|
|
237
254
|
register(command: SlashCommand): void {
|
|
255
|
+
const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
|
|
256
|
+
if (existingByName) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Command registration collision: "${command.name}" is already registered by /${existingByName.name}.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
for (const alias of command.aliases ?? []) {
|
|
262
|
+
const holder = this.commands.get(alias) ?? this.aliasIndex.get(alias);
|
|
263
|
+
if (holder) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Command alias collision: "${alias}" on /${command.name} is already registered by /${holder.name}.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
238
269
|
this.commands.set(command.name, command);
|
|
239
270
|
for (const alias of command.aliases ?? []) {
|
|
240
271
|
this.aliasIndex.set(alias, command);
|