@pellux/goodvibes-tui 0.20.3 → 0.21.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 +27 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -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 +662 -0
- package/src/cli/config-overrides.ts +68 -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 +14 -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 +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -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/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- 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/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- 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 +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -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 +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -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 +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- 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/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- 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 +11 -10
- 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/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 +77 -8
- 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 +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -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 +21 -0
- 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/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,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');
|
|
@@ -120,6 +120,16 @@ export interface CommandShellUiOpeners {
|
|
|
120
120
|
openKnowledgePanel?: () => void;
|
|
121
121
|
openRemotePanel?: () => void;
|
|
122
122
|
openSubscriptionPanel?: () => void;
|
|
123
|
+
/**
|
|
124
|
+
* Open the LocalAuthPanel in masked-password-entry mode for the given
|
|
125
|
+
* operation and username. The panel captures keystrokes into a private
|
|
126
|
+
* buffer; no plaintext password is ever stored in input history, transcript,
|
|
127
|
+
* logs, or recovery files.
|
|
128
|
+
*/
|
|
129
|
+
openLocalAuthMaskedEntry?: (
|
|
130
|
+
kind: 'add-user' | 'rotate-password',
|
|
131
|
+
username: string,
|
|
132
|
+
) => void;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
export interface CommandSessionServices {
|
|
@@ -233,8 +243,28 @@ export class CommandRegistry {
|
|
|
233
243
|
private commands = new Map<string, SlashCommand>();
|
|
234
244
|
private aliasIndex = new Map<string, SlashCommand>();
|
|
235
245
|
|
|
236
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Register a command. Also indexes all aliases for O(1) lookup.
|
|
248
|
+
* Throws if the primary name or any alias collides with an existing
|
|
249
|
+
* registration — silent last-write-wins previously shadowed whole
|
|
250
|
+
* commands, so collisions must fail fast at startup (and are caught
|
|
251
|
+
* statically by the alias lint test).
|
|
252
|
+
*/
|
|
237
253
|
register(command: SlashCommand): void {
|
|
254
|
+
const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
|
|
255
|
+
if (existingByName) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Command registration collision: "${command.name}" is already registered by /${existingByName.name}.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const alias of command.aliases ?? []) {
|
|
261
|
+
const holder = this.commands.get(alias) ?? this.aliasIndex.get(alias);
|
|
262
|
+
if (holder) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Command alias collision: "${alias}" on /${command.name} is already registered by /${holder.name}.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
238
268
|
this.commands.set(command.name, command);
|
|
239
269
|
for (const alias of command.aliases ?? []) {
|
|
240
270
|
this.aliasIndex.set(alias, command);
|
|
@@ -196,9 +196,9 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
|
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
registry.register({
|
|
199
|
-
name: '
|
|
200
|
-
aliases: ['
|
|
201
|
-
description: 'Inspect durable project
|
|
199
|
+
name: 'project-memory',
|
|
200
|
+
aliases: ['pmem'],
|
|
201
|
+
description: 'Inspect durable project memory: risks, runbooks, and architecture notes',
|
|
202
202
|
usage: '[open | queue [limit] | explain <task...> [--scope <path> ...]]',
|
|
203
203
|
handler(args, ctx) {
|
|
204
204
|
const subcommand = (args[0] ?? 'open').toLowerCase();
|
|
@@ -237,7 +237,7 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
|
|
|
237
237
|
});
|
|
238
238
|
const task = taskTokens.join(' ').trim();
|
|
239
239
|
if (!task) {
|
|
240
|
-
ctx.print('Usage: /
|
|
240
|
+
ctx.print('Usage: /project-memory explain <task...> [--scope <path> ...]');
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
243
243
|
const injections = selectKnowledgeForTask(memory, task, scopeValues);
|
|
@@ -249,7 +249,7 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
|
|
|
249
249
|
ctx.openKnowledgePanel();
|
|
250
250
|
return;
|
|
251
251
|
}
|
|
252
|
-
ctx.print(`Unknown
|
|
252
|
+
ctx.print(`Unknown project-memory subcommand: ${subcommand}`);
|
|
253
253
|
},
|
|
254
254
|
});
|
|
255
255
|
}
|
|
@@ -222,7 +222,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
|
|
|
222
222
|
|
|
223
223
|
registry.register({
|
|
224
224
|
name: 'voice',
|
|
225
|
-
description: 'Review
|
|
225
|
+
description: 'Review or toggle always-speak mode (same switch as /tts on|off) and package portable voice metadata',
|
|
226
226
|
usage: '[review|enable|disable|bundle export <path>|bundle inspect <path>]',
|
|
227
227
|
handler(args, ctx) {
|
|
228
228
|
const shellPaths = requireShellPaths(ctx);
|
|
@@ -231,8 +231,9 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
|
|
|
231
231
|
const enabled = Boolean(ctx.platform.configManager.get('ui.voiceEnabled') ?? false);
|
|
232
232
|
ctx.print([
|
|
233
233
|
'Voice Review',
|
|
234
|
-
`
|
|
235
|
-
'
|
|
234
|
+
` always-speak: ${enabled ? 'on' : 'off'}`,
|
|
235
|
+
' config key: ui.voiceEnabled (same as /tts on|off)',
|
|
236
|
+
' posture: optional local TTS output; disabled by default',
|
|
236
237
|
' note: voice remains an optional operator convenience, not a required SaaS dependency',
|
|
237
238
|
].join('\n'));
|
|
238
239
|
return;
|
|
@@ -240,7 +241,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
|
|
|
240
241
|
if (sub === 'enable' || sub === 'disable') {
|
|
241
242
|
const next = sub === 'enable';
|
|
242
243
|
ctx.platform.configManager.setDynamic('ui.voiceEnabled', next);
|
|
243
|
-
ctx.print(`
|
|
244
|
+
ctx.print(`Always-speak mode ${next ? 'enabled' : 'disabled'}. ${next ? 'Every submitted turn will be played through live TTS.' : 'Use /tts <prompt> to speak individual turns.'}`);
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
246
247
|
if (sub === 'bundle') {
|
|
@@ -131,7 +131,7 @@ function renderKnowledgeAskResult(result: KnowledgeAskResult): string {
|
|
|
131
131
|
|
|
132
132
|
export const knowledgeCommand: SlashCommand = {
|
|
133
133
|
name: 'knowledge',
|
|
134
|
-
aliases: ['know'
|
|
134
|
+
aliases: ['know'],
|
|
135
135
|
description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets.',
|
|
136
136
|
usage: '<subcommand> [args]',
|
|
137
137
|
argsHint: 'status|ask|ingest-url|import-bookmarks|import-urls|list|search|get|queue|review-issue|candidates|reports|schedules|lint|packet|explain|reindex|consolidate',
|
|
@@ -17,11 +17,22 @@ export function handleLocalAuthCommand(args: string[], ctx: CommandContext): voi
|
|
|
17
17
|
if (sub === 'add-user') {
|
|
18
18
|
const username = args[1];
|
|
19
19
|
const password = args[2];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
if (!username) {
|
|
21
|
+
ctx.print('Usage: /auth local add-user <username> <password> [roles]\nTip: invoke without a password to use the masked panel: /auth local add-user <username>');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!password) {
|
|
25
|
+
// No password supplied — open masked-entry mode on the LocalAuthPanel.
|
|
26
|
+
if (ctx.openLocalAuthMaskedEntry) {
|
|
27
|
+
ctx.openLocalAuthMaskedEntry('add-user', username);
|
|
28
|
+
} else {
|
|
29
|
+
ctx.print('Masked entry unavailable in this context. Use: /auth local add-user <username> <password>');
|
|
30
|
+
}
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
33
|
+
// Password supplied as argv: warn that the history entry has been scrubbed.
|
|
34
|
+
ctx.print('Warning: passwords passed as command arguments are scrubbed from history, but may appear in shell scrollback. The masked entry is preferred: /auth local add-user <username>');
|
|
35
|
+
const roles = args[3]?.split(',').map((value) => value.trim()).filter(Boolean) ?? ['admin'];
|
|
25
36
|
try {
|
|
26
37
|
const added = auth.addUser(username, password, roles);
|
|
27
38
|
ctx.print(`Added local auth user ${added.username} (${formatRoles(added.roles)}).`);
|
|
@@ -49,10 +60,21 @@ export function handleLocalAuthCommand(args: string[], ctx: CommandContext): voi
|
|
|
49
60
|
if (sub === 'rotate-password') {
|
|
50
61
|
const username = args[1];
|
|
51
62
|
const password = args[2];
|
|
52
|
-
if (!username
|
|
53
|
-
ctx.print('Usage: /auth local rotate-password <username> <password>');
|
|
63
|
+
if (!username) {
|
|
64
|
+
ctx.print('Usage: /auth local rotate-password <username> <password>\nTip: invoke without a password to use the masked panel: /auth local rotate-password <username>');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!password) {
|
|
68
|
+
// No password supplied — open masked-entry mode on the LocalAuthPanel.
|
|
69
|
+
if (ctx.openLocalAuthMaskedEntry) {
|
|
70
|
+
ctx.openLocalAuthMaskedEntry('rotate-password', username);
|
|
71
|
+
} else {
|
|
72
|
+
ctx.print('Masked entry unavailable in this context. Use: /auth local rotate-password <username> <password>');
|
|
73
|
+
}
|
|
54
74
|
return;
|
|
55
75
|
}
|
|
76
|
+
// Password supplied as argv: warn that the history entry has been scrubbed.
|
|
77
|
+
ctx.print('Warning: passwords passed as command arguments are scrubbed from history, but may appear in shell scrollback. The masked entry is preferred: /auth local rotate-password <username>');
|
|
56
78
|
try {
|
|
57
79
|
auth.rotatePassword(username, password);
|
|
58
80
|
ctx.print(`Rotated password for ${username}. Existing sessions were revoked.`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { atomicWriteFileSync } from '../../config/atomic-write.ts';
|
|
3
4
|
import type { CommandRegistry } from '../command-registry.ts';
|
|
4
5
|
import type { ConfigKey } from '../../config/index.ts';
|
|
5
6
|
import { CONFIG_SCHEMA } from '../../config/index.ts';
|
|
@@ -204,18 +205,15 @@ export function registerLocalSetupCommands(registry: CommandRegistry): void {
|
|
|
204
205
|
}
|
|
205
206
|
if (bundle.services) {
|
|
206
207
|
const servicesPath = getShellPaths().resolveProjectPath('tui', 'services.json');
|
|
207
|
-
|
|
208
|
-
writeFileSync(servicesPath, JSON.stringify(bundle.services, null, 2) + '\n', 'utf-8');
|
|
208
|
+
atomicWriteFileSync(servicesPath, JSON.stringify(bundle.services, null, 2) + '\n', { mkdirp: true });
|
|
209
209
|
}
|
|
210
210
|
if (bundle.ecosystem?.plugins) {
|
|
211
211
|
const pluginsPath = getShellPaths().resolveProjectPath('tui', 'ecosystem', 'plugins.json');
|
|
212
|
-
|
|
213
|
-
writeFileSync(pluginsPath, JSON.stringify(bundle.ecosystem.plugins, null, 2) + '\n', 'utf-8');
|
|
212
|
+
atomicWriteFileSync(pluginsPath, JSON.stringify(bundle.ecosystem.plugins, null, 2) + '\n', { mkdirp: true });
|
|
214
213
|
}
|
|
215
214
|
if (bundle.ecosystem?.skills) {
|
|
216
215
|
const skillsPath = getShellPaths().resolveProjectPath('tui', 'ecosystem', 'skills.json');
|
|
217
|
-
|
|
218
|
-
writeFileSync(skillsPath, JSON.stringify(bundle.ecosystem.skills, null, 2) + '\n', 'utf-8');
|
|
216
|
+
atomicWriteFileSync(skillsPath, JSON.stringify(bundle.ecosystem.skills, null, 2) + '\n', { mkdirp: true });
|
|
219
217
|
}
|
|
220
218
|
ctx.print(`Imported setup transfer bundle from ${targetPath}`);
|
|
221
219
|
} catch (error) {
|