@pellux/goodvibes-tui 1.18.0 → 1.19.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.
@@ -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
- const host = String(process.env.GOODVIBES_DAEMON_HOST ?? config.get('controlPlane.host') ?? '127.0.0.1');
145
- const port = Number(config.get('controlPlane.port'));
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