@pellux/goodvibes-tui 1.18.0 → 1.19.1
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 +164 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1421 -896
- package/package.json +7 -7
- package/src/cli/endpoints.ts +50 -4
- package/src/cli/help.ts +29 -2
- package/src/cli/management-commands.ts +17 -8
- package/src/cli/service-posture.ts +5 -3
- package/src/cli/status.ts +27 -6
- package/src/cli/surface-command.ts +29 -15
- package/src/core/memory-diagnostics-gateway.ts +99 -0
- package/src/core/memory-status.ts +118 -0
- package/src/core/voice-provision-gateway.ts +202 -0
- package/src/core/voice-provision-status.ts +197 -0
- package/src/daemon/cli.ts +80 -4
- package/src/input/commands/experience-runtime.ts +25 -4
- package/src/input/commands/health-runtime.ts +15 -1
- package/src/input/tts-settings-actions.ts +27 -8
- package/src/input/voice-local-settings.ts +52 -0
- package/src/runtime/code-index-services.ts +15 -1
- package/src/runtime/knowledge-services.ts +94 -0
- package/src/runtime/legacy-daemon-migration.ts +38 -6
- package/src/runtime/legacy-daemon-reconcile.ts +439 -0
- package/src/runtime/memory-governance-services.ts +79 -0
- package/src/runtime/notification-dispatch.ts +28 -0
- package/src/runtime/services.ts +70 -73
- package/src/runtime/voice-setup-services.ts +52 -0
- package/src/version.ts +11 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// memory-status.ts — the surface-facing projection of the daemon's memory
|
|
3
|
+
// governance (ops.memory.get + OPS_MEMORY_PRESSURE).
|
|
4
|
+
//
|
|
5
|
+
// The SDK's MemoryGovernor owns the policy: it samples RSS/heap against a
|
|
6
|
+
// budget, sheds memory by tier (trimming registered caches, pausing deferrable
|
|
7
|
+
// background jobs), refuses new expensive work at the critical tier, and trips
|
|
8
|
+
// on a genuine leak before the OS OOM-kills it. This module is the read-only
|
|
9
|
+
// projection the /health memory (doctor) surface renders from — the snapshot
|
|
10
|
+
// rows — and the attention line OPS_MEMORY_PRESSURE produces when the tier
|
|
11
|
+
// changes or the tripwire fires.
|
|
12
|
+
//
|
|
13
|
+
// Pure formatting only (no I/O): the command fetches the typed verb response
|
|
14
|
+
// over the operator invoke seam (memory-diagnostics-gateway.ts) and hands it
|
|
15
|
+
// here, so these builders are unit-testable against fixture shapes.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
19
|
+
import type { OpsEvent } from '@/runtime/index.ts';
|
|
20
|
+
|
|
21
|
+
/** ops.memory.get output — the full governor snapshot. */
|
|
22
|
+
export type MemoryGovernorSnapshotResult = OperatorMethodOutput<'ops.memory.get'>;
|
|
23
|
+
/** The OPS_MEMORY_PRESSURE event payload (a tier change or a tripwire firing). */
|
|
24
|
+
export type MemoryPressurePayload = Extract<OpsEvent, { type: 'OPS_MEMORY_PRESSURE' }>;
|
|
25
|
+
|
|
26
|
+
/** Human MB (the governor already reports MB as numbers; round to 1 decimal). */
|
|
27
|
+
export function formatMb(mb: number | null | undefined): string {
|
|
28
|
+
if (mb === null || mb === undefined || !Number.isFinite(mb)) return 'unknown';
|
|
29
|
+
return `${mb >= 100 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Human byte size for a cache footprint estimate. */
|
|
33
|
+
export function formatMemoryBytes(bytes: number | null | undefined): string {
|
|
34
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return 'size n/a';
|
|
35
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
36
|
+
const units = ['KB', 'MB', 'GB'];
|
|
37
|
+
let value = bytes / 1024;
|
|
38
|
+
let unit = 0;
|
|
39
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
40
|
+
value /= 1024;
|
|
41
|
+
unit += 1;
|
|
42
|
+
}
|
|
43
|
+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A plain-language note for the current tier (the governor's posture at this tier). */
|
|
47
|
+
export function memoryTierNote(tier: MemoryGovernorSnapshotResult['tier']): string {
|
|
48
|
+
switch (tier) {
|
|
49
|
+
case 'normal': return 'footprint is comfortably within budget';
|
|
50
|
+
case 'elevated': return 'trimming caches to a floor and running gc';
|
|
51
|
+
case 'high': return 'flushing caches and pausing deferrable background jobs';
|
|
52
|
+
case 'critical': return 'refusing new expensive work until the footprint recovers';
|
|
53
|
+
default: return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The /health memory (doctor) rows — the honest account of the daemon defending
|
|
59
|
+
* its footprint. Order: tier + posture, budget vs RSS, heap, tier thresholds,
|
|
60
|
+
* per-cache footprints the governor can shrink, which deferrable jobs are
|
|
61
|
+
* paused, and the leak-tripwire state.
|
|
62
|
+
*/
|
|
63
|
+
export function memoryStatusLines(snapshot: MemoryGovernorSnapshotResult): string[] {
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
lines.push(` tier: ${snapshot.tier} — ${memoryTierNote(snapshot.tier)}`);
|
|
66
|
+
if (snapshot.refusingExpensiveWork) {
|
|
67
|
+
lines.push(' expensive work: REFUSED (critical tier) — new ingestion / reindex / voice install is declined until RSS recovers');
|
|
68
|
+
}
|
|
69
|
+
lines.push(` budget: ${formatMb(snapshot.rssMb)} rss / ${formatMb(snapshot.budgetMb)} budget (${Math.round(snapshot.usedPct)}%)`);
|
|
70
|
+
const heap = snapshot.heapTotalMb !== undefined
|
|
71
|
+
? `${formatMb(snapshot.heapUsedMb)} used / ${formatMb(snapshot.heapTotalMb)} total`
|
|
72
|
+
: formatMb(snapshot.heapUsedMb);
|
|
73
|
+
lines.push(` heap: ${heap}`);
|
|
74
|
+
lines.push(` tiers: elevated ${snapshot.thresholds.elevatedPct}% · high ${snapshot.thresholds.highPct}% · critical ${snapshot.thresholds.criticalPct}%`);
|
|
75
|
+
|
|
76
|
+
if (snapshot.caches.length === 0) {
|
|
77
|
+
lines.push(' caches: none registered');
|
|
78
|
+
} else {
|
|
79
|
+
lines.push(` caches: ${snapshot.caches.length}`);
|
|
80
|
+
for (const cache of snapshot.caches) {
|
|
81
|
+
const size = cache.estimatedBytes !== undefined ? `, ~${formatMemoryBytes(cache.estimatedBytes)}` : '';
|
|
82
|
+
lines.push(` ${cache.id} (${cache.name}): ${cache.entries} entries${size}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push(snapshot.pausedJobs.length === 0
|
|
87
|
+
? ' paused jobs: none'
|
|
88
|
+
: ` paused jobs: ${snapshot.pausedJobs.join(', ')}`);
|
|
89
|
+
|
|
90
|
+
if (snapshot.tripwire.armed) {
|
|
91
|
+
lines.push(` tripwire: ARMED — watching for a sustained >${snapshot.tripwire.rateMbPerSec} MB/s post-flush leak (held ${Math.round(snapshot.tripwire.sustainedSec)}s)`);
|
|
92
|
+
} else {
|
|
93
|
+
lines.push(' tripwire: disarmed (no post-flush leak growth under watch)');
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The attention line OPS_MEMORY_PRESSURE produces: a one-liner naming the new
|
|
100
|
+
* tier and the RSS/budget, plus a distinct escalation when the leak tripwire
|
|
101
|
+
* fires (the daemon is about to exit for a clean supervisor restart). Returned
|
|
102
|
+
* as a single string so the notice/attention surface can push it directly.
|
|
103
|
+
*/
|
|
104
|
+
export function memoryPressureLine(event: MemoryPressurePayload): string {
|
|
105
|
+
const base = `memory pressure: ${event.previousTier} → ${event.tier} (${formatMb(event.rssMb)} rss / ${formatMb(event.budgetMb)} budget, ${Math.round(event.usedPct)}%)`;
|
|
106
|
+
if (event.tripwire) {
|
|
107
|
+
return `${base} — leak tripwire fired (${event.tripwire.rateMbPerSec} MB/s sustained ${Math.round(event.tripwire.sustainedSec)}s); the daemon will exit for a clean restart`;
|
|
108
|
+
}
|
|
109
|
+
if (event.note) return `${base} — ${event.note}`;
|
|
110
|
+
return base;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Severity for the notice feed: critical tier (or a tripwire) is critical; high is a warning. */
|
|
114
|
+
export function memoryPressureLevel(event: MemoryPressurePayload): 'info' | 'warning' | 'critical' {
|
|
115
|
+
if (event.tripwire || event.tier === 'critical') return 'critical';
|
|
116
|
+
if (event.tier === 'high') return 'warning';
|
|
117
|
+
return 'info';
|
|
118
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// voice-provision-gateway.ts
|
|
3
|
+
//
|
|
4
|
+
// The daemon-backed verbs the /voice setup + /voice status surfaces read:
|
|
5
|
+
// voice.local.status (GET /api/voice/local/status) — the no-network installed
|
|
6
|
+
// snapshot — and voice.local.install (POST /api/voice/local/install) — the
|
|
7
|
+
// one-act managed install. Neither has a named facade on the in-process
|
|
8
|
+
// OperatorClient, so — exactly like the memory-consolidation gateway — they go
|
|
9
|
+
// over the generic operator invoke path (operator-rpc.ts's resolveOperatorRpc
|
|
10
|
+
// -> sdk.operator.invoke), reaching the SAME daemon the command layer does.
|
|
11
|
+
//
|
|
12
|
+
// A daemon that predates managed voice provisioning answers an honest 501 (verb
|
|
13
|
+
// cataloged but no handler) or a 404 (older daemon without the route) — both
|
|
14
|
+
// are "verb unavailable", NOT a fabricated empty/failed install (see
|
|
15
|
+
// classifyVoiceProvisionError). The interface is injectable so the command and
|
|
16
|
+
// its tests round-trip against a mocked daemon.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
20
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
|
|
21
|
+
import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
|
|
22
|
+
import type { VoiceRuntimeStatusResult, VoiceLocalInstallResult } from './voice-provision-status.ts';
|
|
23
|
+
import { voiceStatusLines, voiceInstallReceiptLines, voiceInstallComponentLine } from './voice-provision-status.ts';
|
|
24
|
+
|
|
25
|
+
/** The narrow async verb surface the /voice setup + /voice status surfaces drive. */
|
|
26
|
+
export interface VoiceProvisionGateway {
|
|
27
|
+
/** Read the no-network managed-runtime snapshot (voice.local.status). */
|
|
28
|
+
fetchStatus(): Promise<VoiceRuntimeStatusResult>;
|
|
29
|
+
/** Run the one-act managed install (voice.local.install); resolves to the receipt. */
|
|
30
|
+
runInstall(): Promise<VoiceLocalInstallResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Why the gateway could not be built (daemon disabled / no control-plane URL),
|
|
35
|
+
* surfaced verbatim so the surface prints an honest "unavailable" line rather
|
|
36
|
+
* than guessing — mirrors MemoryConsolidationGatewayResolution.
|
|
37
|
+
*/
|
|
38
|
+
export type VoiceProvisionGatewayResolution =
|
|
39
|
+
| { readonly available: true; readonly gateway: VoiceProvisionGateway }
|
|
40
|
+
| { readonly available: false; readonly reason: string };
|
|
41
|
+
|
|
42
|
+
export interface VoiceProvisionGatewayDeps {
|
|
43
|
+
readonly configManager: ConfigManager;
|
|
44
|
+
readonly homeDirectory: string | (() => string);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the live voice-provisioning gateway over the generic operator invoke
|
|
49
|
+
* path — the same daemon resolution the command layer uses. Returns an honest
|
|
50
|
+
* unavailable reason when no daemon is reachable, so the surface refuses
|
|
51
|
+
* cleanly instead of throwing mid-render.
|
|
52
|
+
*/
|
|
53
|
+
export function createVoiceProvisionGateway(deps: VoiceProvisionGatewayDeps): VoiceProvisionGatewayResolution {
|
|
54
|
+
const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
|
|
55
|
+
if (!rpc.available) return { available: false, reason: rpc.reason };
|
|
56
|
+
const { sdk } = rpc;
|
|
57
|
+
return {
|
|
58
|
+
available: true,
|
|
59
|
+
gateway: {
|
|
60
|
+
fetchStatus: () => sdk.operator.invoke('voice.local.status', {}),
|
|
61
|
+
runInstall: () => sdk.operator.invoke('voice.local.install', {}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** How a voice verb rejection should render. */
|
|
67
|
+
export type VoiceProvisionFetchFailure =
|
|
68
|
+
| { readonly kind: 'unavailable'; readonly reason: string }
|
|
69
|
+
| { readonly kind: 'error'; readonly message: string };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify a voice verb rejection: a 501 (a daemon that predates managed voice
|
|
73
|
+
* provisioning — cataloged but unhandled) or a 404 (an older daemon without the
|
|
74
|
+
* route) are both "verb unavailable", distinct from a generic request failure
|
|
75
|
+
* (network error, 401/403, 500). Reuses describeOperatorRpcError's wording so
|
|
76
|
+
* the call sites never diverge on how they describe the same daemon response.
|
|
77
|
+
*/
|
|
78
|
+
export function classifyVoiceProvisionError(error: unknown): VoiceProvisionFetchFailure {
|
|
79
|
+
if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
|
|
80
|
+
return { kind: 'unavailable', reason: 'this daemon does not serve managed voice provisioning yet.' };
|
|
81
|
+
}
|
|
82
|
+
return { kind: 'error', message: describeOperatorRpcError(error) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The single one-act setup announcement shown before the (possibly long) install runs. */
|
|
86
|
+
export const VOICE_SETUP_ANNOUNCEMENT = [
|
|
87
|
+
'Local Voice Setup',
|
|
88
|
+
' provisioning the managed local voice runtime (piper TTS + a default voice; whisper STT where a bundle is published)…',
|
|
89
|
+
' downloads are checksum-verified and resumable — re-run /voice setup to retry any failed component.',
|
|
90
|
+
].join('\n');
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render the /voice status or /voice setup RESULT block from an already-resolved
|
|
94
|
+
* gateway resolution — the testable core the command wraps. Kept separate from
|
|
95
|
+
* the live gateway construction so a wire test injects a fake resolution
|
|
96
|
+
* (available / unavailable / failing) with no HTTP, mirroring the Memory modal's
|
|
97
|
+
* injectable resolveConsolidationGateway seam. The caller prints
|
|
98
|
+
* VOICE_SETUP_ANNOUNCEMENT itself before awaiting a setup so the up-front notice
|
|
99
|
+
* shows during the (possibly long, multi-hundred-MB) install.
|
|
100
|
+
*/
|
|
101
|
+
export async function renderVoiceProvision(
|
|
102
|
+
sub: 'status' | 'setup',
|
|
103
|
+
resolution: VoiceProvisionGatewayResolution,
|
|
104
|
+
): Promise<string> {
|
|
105
|
+
if (!resolution.available) {
|
|
106
|
+
const label = sub === 'status' ? 'Local Voice Runtime' : 'Local Voice Setup';
|
|
107
|
+
return `${label} unavailable: ${resolution.reason}`;
|
|
108
|
+
}
|
|
109
|
+
if (sub === 'status') {
|
|
110
|
+
try {
|
|
111
|
+
const status = await resolution.gateway.fetchStatus();
|
|
112
|
+
return ['Local Voice Runtime', ...voiceStatusLines(status)].join('\n');
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const failure = classifyVoiceProvisionError(error);
|
|
115
|
+
return failure.kind === 'unavailable'
|
|
116
|
+
? `Local Voice Runtime\n ${failure.reason}`
|
|
117
|
+
: `Local Voice Runtime\n could not read status: ${failure.message}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// setup
|
|
121
|
+
try {
|
|
122
|
+
const receipt = await resolution.gateway.runInstall();
|
|
123
|
+
return ['Local Voice Setup — receipt', ...voiceInstallReceiptLines(receipt)].join('\n');
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const failure = classifyVoiceProvisionError(error);
|
|
126
|
+
return failure.kind === 'unavailable'
|
|
127
|
+
? `Local Voice Setup\n ${failure.reason}`
|
|
128
|
+
: `Local Voice Setup\n install failed: ${failure.message}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface VoiceSetupProgressOptions {
|
|
133
|
+
/** Sink for each printed block (the command passes ctx.print). */
|
|
134
|
+
readonly print: (block: string) => void;
|
|
135
|
+
/** Poll interval while the install is in flight (default 800ms). */
|
|
136
|
+
readonly pollMs?: number;
|
|
137
|
+
/** Injectable delay (tests pass a fake; the command uses setTimeout). */
|
|
138
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run /voice setup with LIVE progress: kick off the (long, request/response)
|
|
145
|
+
* install and, while it is in flight, poll voice.local.status for its
|
|
146
|
+
* `installInProgress` snapshot, printing a per-component line each time a
|
|
147
|
+
* component's phase advances (download → verify → extract → done). When the
|
|
148
|
+
* install settles, print the final receipt (or an honest failure). The install
|
|
149
|
+
* verb streams nothing itself — this polling is exactly how the SDK intends
|
|
150
|
+
* surfaces to render real progress instead of busy→receipt.
|
|
151
|
+
*
|
|
152
|
+
* The caller prints VOICE_SETUP_ANNOUNCEMENT before calling this. gateway
|
|
153
|
+
* fetchStatus/runInstall and sleep are injectable so a wire test drives the
|
|
154
|
+
* whole flow with no real timers or HTTP.
|
|
155
|
+
*/
|
|
156
|
+
export async function runVoiceSetupWithProgress(
|
|
157
|
+
resolution: VoiceProvisionGatewayResolution,
|
|
158
|
+
opts: VoiceSetupProgressOptions,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
if (!resolution.available) {
|
|
161
|
+
opts.print(`Local Voice Setup unavailable: ${resolution.reason}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const { gateway } = resolution;
|
|
165
|
+
const pollMs = opts.pollMs ?? 800;
|
|
166
|
+
const sleep = opts.sleep ?? realSleep;
|
|
167
|
+
|
|
168
|
+
let settled = false;
|
|
169
|
+
const installPromise = gateway.runInstall().then(
|
|
170
|
+
(receipt) => { settled = true; return { ok: true as const, receipt }; },
|
|
171
|
+
(error) => { settled = true; return { ok: false as const, error }; },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Print a component line only when its phase changes, so a slow install does
|
|
175
|
+
// not spam identical lines each poll.
|
|
176
|
+
const lastPhase = new Map<string, string>();
|
|
177
|
+
while (!settled) {
|
|
178
|
+
await sleep(pollMs);
|
|
179
|
+
if (settled) break;
|
|
180
|
+
try {
|
|
181
|
+
const status = await gateway.fetchStatus();
|
|
182
|
+
const progress = status.installInProgress;
|
|
183
|
+
if (!progress) continue;
|
|
184
|
+
const changed = progress.components.filter((c) => lastPhase.get(c.component) !== c.phase);
|
|
185
|
+
for (const c of changed) lastPhase.set(c.component, c.phase);
|
|
186
|
+
if (changed.length > 0) opts.print(changed.map(voiceInstallComponentLine).join('\n'));
|
|
187
|
+
} catch {
|
|
188
|
+
// A transient poll failure never aborts the install; the receipt/failure
|
|
189
|
+
// block below is the authoritative outcome.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await installPromise;
|
|
194
|
+
if (result.ok) {
|
|
195
|
+
opts.print(['Local Voice Setup — receipt', ...voiceInstallReceiptLines(result.receipt)].join('\n'));
|
|
196
|
+
} else {
|
|
197
|
+
const failure = classifyVoiceProvisionError(result.error);
|
|
198
|
+
opts.print(failure.kind === 'unavailable'
|
|
199
|
+
? `Local Voice Setup\n ${failure.reason}`
|
|
200
|
+
: `Local Voice Setup\n install failed: ${failure.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// voice-provision-status.ts — the surface-facing projection of the managed
|
|
3
|
+
// local-voice runtime (voice.local.status / voice.local.install).
|
|
4
|
+
//
|
|
5
|
+
// The SDK owns the provisioning policy: a one-act install that fetches the
|
|
6
|
+
// piper TTS engine + a default voice (fully hosted) and, where a pinned bundle
|
|
7
|
+
// is hosted for the platform, the whisper STT engine + model — atomic,
|
|
8
|
+
// checksum-verified, resumable, and honest about every terminal state. This
|
|
9
|
+
// module is the read-only projection the /voice surfaces render from: sizes
|
|
10
|
+
// declared up front, a receipt of what was installed / skipped / failed, and
|
|
11
|
+
// the honest "not yet published for this platform" line when a whisper bundle
|
|
12
|
+
// is pinned but not yet hosted (its manifest url is unstamped).
|
|
13
|
+
//
|
|
14
|
+
// Pure formatting only (no I/O): the command layer fetches the typed verb
|
|
15
|
+
// responses over the operator invoke seam (voice-provision-gateway.ts) and
|
|
16
|
+
// hands them here, so these builders are unit-testable against fixture shapes.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
20
|
+
|
|
21
|
+
/** voice.local.status output — the no-network installed/available snapshot. */
|
|
22
|
+
export type VoiceRuntimeStatusResult = OperatorMethodOutput<'voice.local.status'>;
|
|
23
|
+
/** voice.local.install receipt — the one-act install outcome. */
|
|
24
|
+
export type VoiceLocalInstallResult = OperatorMethodOutput<'voice.local.install'>;
|
|
25
|
+
/** The live per-component progress carried on voice.local.status WHILE an install runs. */
|
|
26
|
+
export type VoiceInstallProgress = NonNullable<VoiceRuntimeStatusResult['installInProgress']>;
|
|
27
|
+
/** One live install component's progress (latest phase wins). */
|
|
28
|
+
export type VoiceInstallComponentProgress = VoiceInstallProgress['components'][number];
|
|
29
|
+
|
|
30
|
+
/** Human byte size (mirrors the local formatBytes idiom used across the renderer). */
|
|
31
|
+
export function formatVoiceBytes(bytes: number | null | undefined): string {
|
|
32
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return 'unknown size';
|
|
33
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
34
|
+
const units = ['KB', 'MB', 'GB'];
|
|
35
|
+
let value = bytes / 1024;
|
|
36
|
+
let unit = 0;
|
|
37
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
38
|
+
value /= 1024;
|
|
39
|
+
unit += 1;
|
|
40
|
+
}
|
|
41
|
+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when a whisper STT bundle is PINNED for this platform (bytes + checksum
|
|
46
|
+
* fixed) but not yet hosted/sideloaded — the "supported but not yet published"
|
|
47
|
+
* state. The SDK signals it as supported === true with an unprovisioned state
|
|
48
|
+
* and an honest reason string; contrast an unsupported platform (supported ===
|
|
49
|
+
* false), which has no pin at all.
|
|
50
|
+
*/
|
|
51
|
+
export function isSttPinnedButUnpublished(status: VoiceRuntimeStatusResult): boolean {
|
|
52
|
+
const stt = status.stt;
|
|
53
|
+
return stt.supported === true && stt.state !== 'provisioned' && !stt.binaryPresent && !stt.modelPresent
|
|
54
|
+
&& typeof stt.reason === 'string' && stt.reason.length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether the managed runtime still has something to offer to provision. */
|
|
58
|
+
export function voiceRuntimeNeedsSetup(status: VoiceRuntimeStatusResult): boolean {
|
|
59
|
+
return status.state !== 'provisioned' || status.stt.state === 'not-provisioned' || status.stt.state === 'partial';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The /voice status lines — an honest, plain-language account of the managed
|
|
64
|
+
* local-voice runtime with sizes declared up front. Order: platform + overall
|
|
65
|
+
* state, then the TTS (piper) engine, then the STT (whisper) engine with its
|
|
66
|
+
* honest unsupported / not-yet-published wording, then the one-act offer size.
|
|
67
|
+
*/
|
|
68
|
+
export function voiceStatusLines(status: VoiceRuntimeStatusResult): string[] {
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
lines.push(` platform: ${status.platform ?? 'unrecognized (no managed voice build)'}`);
|
|
71
|
+
lines.push(` runtime: ${status.state}`);
|
|
72
|
+
|
|
73
|
+
// TTS — piper, fully hosted where a platform build exists.
|
|
74
|
+
const tts = status.tts;
|
|
75
|
+
if (tts.binaryPresent && tts.voicePresent) {
|
|
76
|
+
lines.push(` tts (${tts.engine}): installed`);
|
|
77
|
+
} else if (status.state === 'unsupported-platform') {
|
|
78
|
+
lines.push(` tts (${tts.engine}): unsupported on this platform`);
|
|
79
|
+
} else {
|
|
80
|
+
const have = [tts.binaryPresent ? 'engine' : null, tts.voicePresent ? 'voice' : null].filter(Boolean);
|
|
81
|
+
lines.push(` tts (${tts.engine}): not installed${have.length ? ` (have ${have.join(' + ')})` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// STT — whisper, hosted only where a pinned bundle is published. Render the
|
|
85
|
+
// REAL state honestly: a platform with no pinned build (supported === false,
|
|
86
|
+
// or state 'unsupported-platform' — every platform except linux-x64 today)
|
|
87
|
+
// reads "unsupported on this platform"; a platform whose bundle is pinned but
|
|
88
|
+
// not yet hosted (supported === true, unprovisioned, with a reason) reads
|
|
89
|
+
// "not yet published for this platform".
|
|
90
|
+
const stt = status.stt;
|
|
91
|
+
if (stt.binaryPresent && stt.modelPresent) {
|
|
92
|
+
lines.push(` stt (${stt.engine}): installed`);
|
|
93
|
+
} else if (!stt.supported || stt.state === 'unsupported-platform') {
|
|
94
|
+
lines.push(` stt (${stt.engine}): unsupported on this platform`);
|
|
95
|
+
if (stt.reason) lines.push(` ${stt.reason}`);
|
|
96
|
+
} else if (isSttPinnedButUnpublished(status)) {
|
|
97
|
+
lines.push(` stt (${stt.engine}): not yet published for this platform`);
|
|
98
|
+
if (stt.reason) lines.push(` ${stt.reason}`);
|
|
99
|
+
} else {
|
|
100
|
+
lines.push(` stt (${stt.engine}): not installed`);
|
|
101
|
+
if (stt.reason) lines.push(` ${stt.reason}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (status.offerBytes !== null && status.offerBytes !== undefined && voiceRuntimeNeedsSetup(status)) {
|
|
105
|
+
lines.push(` setup download: ${formatVoiceBytes(status.offerBytes)} — run /voice setup to install`);
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A human phase label for a live install component. */
|
|
111
|
+
export function voiceProgressPhaseLabel(phase: string): string {
|
|
112
|
+
switch (phase) {
|
|
113
|
+
case 'skip': return 'already present';
|
|
114
|
+
case 'download': return 'downloading';
|
|
115
|
+
case 'verify': return 'verifying';
|
|
116
|
+
case 'extract': return 'extracting';
|
|
117
|
+
case 'done': return 'done';
|
|
118
|
+
case 'error': return 'failed';
|
|
119
|
+
default: return phase;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One live progress line for an install component (size shown as done/total where known). */
|
|
124
|
+
export function voiceInstallComponentLine(component: VoiceInstallComponentProgress): string {
|
|
125
|
+
let size = '';
|
|
126
|
+
if (component.bytesTotal !== undefined) {
|
|
127
|
+
const done = component.bytesDone !== undefined ? `${formatVoiceBytes(component.bytesDone)}/` : '';
|
|
128
|
+
size = ` (${done}${formatVoiceBytes(component.bytesTotal)})`;
|
|
129
|
+
}
|
|
130
|
+
const msg = component.message ? ` — ${component.message}` : '';
|
|
131
|
+
return ` ${component.component}: ${voiceProgressPhaseLabel(component.phase)}${size}${msg}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Render the live per-component install progress (voice.local.status installInProgress). */
|
|
135
|
+
export function voiceInstallProgressLines(progress: VoiceInstallProgress): string[] {
|
|
136
|
+
return progress.components.map(voiceInstallComponentLine);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The honest per-terminal-state one-liner for a TTS/STT engine outcome. */
|
|
140
|
+
export function voiceEngineOutcomeLine(engine: string, state: string, reason: string | undefined): string {
|
|
141
|
+
switch (state) {
|
|
142
|
+
case 'provisioned':
|
|
143
|
+
return ` ${engine}: installed`;
|
|
144
|
+
case 'download-failed':
|
|
145
|
+
return ` ${engine}: download failed${reason ? ` — ${reason}` : ''} (re-run /voice setup to retry; completed parts are kept)`;
|
|
146
|
+
case 'checksum-mismatch':
|
|
147
|
+
return ` ${engine}: checksum mismatch — the downloaded file did not match its pinned checksum and was discarded${reason ? ` (${reason})` : ''}`;
|
|
148
|
+
case 'bundle-unavailable':
|
|
149
|
+
return ` ${engine}: not yet published for this platform${reason ? ` — ${reason}` : ''}`;
|
|
150
|
+
case 'sideload-mismatch':
|
|
151
|
+
return ` ${engine}: a sideloaded bundle was present but did not match the pinned checksum${reason ? ` — ${reason}` : ''}`;
|
|
152
|
+
case 'unsupported-platform':
|
|
153
|
+
return ` ${engine}: unsupported on this platform${reason ? ` — ${reason}` : ''}`;
|
|
154
|
+
default:
|
|
155
|
+
return ` ${engine}: ${state}${reason ? ` — ${reason}` : ''}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The /voice setup receipt lines — the final one-act outcome. Renders the
|
|
161
|
+
* per-component breakdown (installed / skipped-as-present / failed with size),
|
|
162
|
+
* the TTS and STT terminal states with honest wording, and the config write
|
|
163
|
+
* receipt (which voice.local.* keys were configured vs skipped as user-set).
|
|
164
|
+
*/
|
|
165
|
+
export function voiceInstallReceiptLines(result: VoiceLocalInstallResult): string[] {
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
lines.push(` result: ${result.provisioned ? 'local voice provisioned' : 'not provisioned'}`);
|
|
168
|
+
lines.push(` platform: ${result.platform ?? 'unrecognized (no managed voice build)'}`);
|
|
169
|
+
|
|
170
|
+
lines.push(voiceEngineOutcomeLine(`tts (${result.tts.engine})`, result.tts.state, result.tts.reason));
|
|
171
|
+
lines.push(voiceEngineOutcomeLine(`stt (${result.stt.engine})`, result.stt.state, result.stt.reason));
|
|
172
|
+
|
|
173
|
+
if (result.components.length > 0) {
|
|
174
|
+
lines.push(' components:');
|
|
175
|
+
for (const component of result.components) {
|
|
176
|
+
const size = component.bytes !== undefined ? ` (${formatVoiceBytes(component.bytes)})` : '';
|
|
177
|
+
const err = component.error ? ` — ${component.error}` : '';
|
|
178
|
+
const state = component.state === 'skipped' ? 'already present' : component.state;
|
|
179
|
+
lines.push(` ${component.id}: ${state}${size}${err}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const set = result.configured.set;
|
|
184
|
+
const skipped = result.configured.skipped;
|
|
185
|
+
if (set.length > 0) {
|
|
186
|
+
lines.push(` configured ${set.length} key${set.length === 1 ? '' : 's'}:`);
|
|
187
|
+
for (const entry of set) lines.push(` ${entry.key} = ${entry.value}`);
|
|
188
|
+
}
|
|
189
|
+
if (skipped.length > 0) {
|
|
190
|
+
lines.push(` left ${skipped.length} key${skipped.length === 1 ? '' : 's'} untouched:`);
|
|
191
|
+
for (const entry of skipped) lines.push(` ${entry.key}: ${entry.reason}`);
|
|
192
|
+
}
|
|
193
|
+
if (set.length === 0 && skipped.length === 0) {
|
|
194
|
+
lines.push(' no config keys were written');
|
|
195
|
+
}
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
package/src/daemon/cli.ts
CHANGED
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
} from '@pellux/goodvibes-sdk/platform/discovery';
|
|
30
30
|
import { createSafeHostServeFactory } from './safe-serve.ts';
|
|
31
31
|
import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
|
|
32
|
+
import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
|
|
33
|
+
import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
|
|
34
|
+
import { resolveRuntimeEndpointBinding } from '../cli/endpoints.ts';
|
|
32
35
|
import { resolveDaemonUpdateArtifact } from './lifecycle.ts';
|
|
33
36
|
import { VERSION } from '../version.ts';
|
|
34
37
|
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
parseGoodVibesCli,
|
|
37
40
|
renderGoodVibesDaemonHelp,
|
|
38
41
|
renderGoodVibesVersion,
|
|
42
|
+
renderDaemonStartupBanner,
|
|
39
43
|
applyRuntimeConfigOverrides,
|
|
40
44
|
applyRuntimeConfigValue,
|
|
41
45
|
applyRuntimeFeatureFlagOverrides,
|
|
@@ -141,15 +145,28 @@ async function main(): Promise<void> {
|
|
|
141
145
|
// no runtime/services are constructed — and exit with the honest result code.
|
|
142
146
|
const serviceSubcommand = cli.positionals[0];
|
|
143
147
|
if (isDaemonServiceSubcommand(serviceSubcommand)) {
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
// The host/port baked into the unit's ExecStart (and displayed) come from
|
|
149
|
+
// the SAME hostMode-aware resolution the SDK bind path uses — never from
|
|
150
|
+
// the GOODVIBES_DAEMON_HOST env var, which nothing in the bind path reads
|
|
151
|
+
// (the --hostname flag already lands in config via
|
|
152
|
+
// applyRuntimeEndpointFlagOverrides above, so it is covered here).
|
|
153
|
+
const binding = resolveRuntimeEndpointBinding(config, 'controlPlane');
|
|
154
|
+
if (!binding.recognized) {
|
|
155
|
+
// The SDK bind path has no default case for an unrecognized hostMode —
|
|
156
|
+
// a daemon launched with this config throws before binding. Say so
|
|
157
|
+
// instead of presenting the fallback values as a real binding.
|
|
158
|
+
console.warn(
|
|
159
|
+
`[goodvibes-daemon] warning: controlPlane.hostMode '${binding.hostMode}' is not a recognized mode ` +
|
|
160
|
+
"(local|network|custom) — the daemon will fail to start until it is corrected.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
146
163
|
const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
|
|
147
164
|
const result = await runDaemonServiceCli({
|
|
148
165
|
subcommand: serviceSubcommand,
|
|
149
166
|
binaryPath,
|
|
150
167
|
homeDir: homeDirectory,
|
|
151
|
-
host,
|
|
152
|
-
port,
|
|
168
|
+
host: binding.host,
|
|
169
|
+
port: binding.port,
|
|
153
170
|
// migrate-service only: never auto-migrate — requires the same explicit
|
|
154
171
|
// consent as any other non-interactive destructive confirmation.
|
|
155
172
|
confirmMigration: cliFlags.yes,
|
|
@@ -161,6 +178,28 @@ async function main(): Promise<void> {
|
|
|
161
178
|
process.exit(result.exitCode);
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
// Honest startup identity, printed for EVERY launch shape — including a bare
|
|
182
|
+
// (no-arg) systemd launch, and BEFORE any runtime construction so it still
|
|
183
|
+
// reaches the journal when a broken config makes the daemon throw during
|
|
184
|
+
// composition. It states the resolved version (never a placeholder) and the
|
|
185
|
+
// home/host/port the daemon will actually bind: the binding comes from the
|
|
186
|
+
// SAME hostMode-aware resolution the SDK bind path uses (resolveHostBinding:
|
|
187
|
+
// 'local' forces 127.0.0.1, 'network' forces 0.0.0.0, port 0/non-numeric
|
|
188
|
+
// falls back to the default) — never from controlPlane.host alone, and never
|
|
189
|
+
// from the GOODVIBES_DAEMON_HOST env var, which the bind path does not read.
|
|
190
|
+
const bannerBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.log(renderDaemonStartupBanner(VERSION, { homeDir: homeDirectory, host: bannerBinding.host, port: bannerBinding.port }));
|
|
193
|
+
if (!bannerBinding.recognized) {
|
|
194
|
+
// An unrecognized hostMode has NO binding the SDK can produce (its
|
|
195
|
+
// resolver has no default case and the daemon will throw below, before
|
|
196
|
+
// serving). Warn here so the journaled crash is explained.
|
|
197
|
+
console.warn(
|
|
198
|
+
`[goodvibes-daemon] warning: controlPlane.hostMode '${bannerBinding.hostMode}' is not a recognized mode ` +
|
|
199
|
+
"(local|network|custom) — the daemon cannot bind until it is corrected; the host/port above are fallback values, not a real binding.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
164
203
|
const runtimeBus = new RuntimeEventBus();
|
|
165
204
|
const runtimeStore = createRuntimeStore();
|
|
166
205
|
// Gate states derive from the domain settings keys; the bridge keeps live
|
|
@@ -295,6 +334,43 @@ async function main(): Promise<void> {
|
|
|
295
334
|
httpListener: config.get('danger.httpListener'),
|
|
296
335
|
});
|
|
297
336
|
|
|
337
|
+
// Cheap unattended reconcile: if this (canonical) daemon unit is confirmed
|
|
338
|
+
// serving AND a redundant installer-managed goodvibes-daemon.service (the
|
|
339
|
+
// retired unit name) sits enabled-but-NOT-running beside it — the exact
|
|
340
|
+
// production-incident state — auto-disable and remove it, printing a
|
|
341
|
+
// receipt. A RUNNING legacy daemon, a hand-written unit, or an unanswered
|
|
342
|
+
// configured endpoint all refuse with a notice instead. Best-effort: never
|
|
343
|
+
// let this block or crash daemon boot (per-call systemctl timeouts plus one
|
|
344
|
+
// cumulative pass deadline). The unit search root is the LOGIN user's home
|
|
345
|
+
// (where systemd user units live), never the daemon data home
|
|
346
|
+
// (GOODVIBES_DAEMON_HOME); the tracked name honors service.serviceName. The
|
|
347
|
+
// endpoint requirement uses the CLIENT view of the config — a fresh read of
|
|
348
|
+
// settings.json with none of this process's runtime flag overrides — because
|
|
349
|
+
// that is what clients resolve when they look for the daemon.
|
|
350
|
+
try {
|
|
351
|
+
const clientViewConfig = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
|
|
352
|
+
const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
|
|
353
|
+
const reconcile = await reconcileRedundantLegacyUnit({
|
|
354
|
+
homeDir: homedir(),
|
|
355
|
+
trackedServiceName: resolveConfiguredServiceName(clientViewConfig),
|
|
356
|
+
configuredEndpoint: { host: clientEndpoint.host, port: clientEndpoint.port },
|
|
357
|
+
});
|
|
358
|
+
if (reconcile.action !== 'noop') {
|
|
359
|
+
for (const line of reconcile.lines) {
|
|
360
|
+
// eslint-disable-next-line no-console
|
|
361
|
+
console.log(line);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (reconcile.reason !== 'no-legacy-unit') {
|
|
365
|
+
// Breadcrumb for EVERY outcome where a legacy unit file exists —
|
|
366
|
+
// including guard refusals — so a persisting two-unit state is never
|
|
367
|
+
// silent about why nothing was reconciled.
|
|
368
|
+
logger.info('daemon: legacy-unit reconcile', { action: reconcile.action, reason: reconcile.reason });
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
logger.warn('daemon: legacy-unit reconcile failed (non-fatal)', { error: summarizeError(error) });
|
|
372
|
+
}
|
|
373
|
+
|
|
298
374
|
// Print a device-pairing QR to stdout. The QR encodes the canonical
|
|
299
375
|
// `#pair=<token>` deep link the web app consumes — a camera scan opens it
|
|
300
376
|
// already signed in. No raw JSON connection blob is printed. This is also the
|