@pellux/goodvibes-agent 1.5.7 → 1.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +1 -1
  3. package/dist/package/main.js +309 -150
  4. package/package.json +1 -1
  5. package/src/agent/memory-prompt.ts +3 -3
  6. package/src/agent/operator-actions.ts +1 -1
  7. package/src/agent/prompt-context-receipts.ts +3 -3
  8. package/src/agent/session-registration.ts +1 -1
  9. package/src/agent/vibe-file.ts +5 -5
  10. package/src/audio/player.ts +91 -5
  11. package/src/audio/spoken-turn-controller.ts +281 -29
  12. package/src/audio/spoken-turn-wiring.ts +13 -2
  13. package/src/cli/local-library-command-shared.ts +1 -1
  14. package/src/cli/memory-command.ts +9 -6
  15. package/src/cli/resume-relaunch-notice.ts +1 -1
  16. package/src/cli/tui-startup.ts +1 -1
  17. package/src/config/credential-status.ts +1 -1
  18. package/src/config/index.ts +1 -1
  19. package/src/config/secrets.ts +1 -1
  20. package/src/core/conversation-rendering.ts +2 -2
  21. package/src/core/system-message-noise.ts +3 -3
  22. package/src/core/thinking-overlay.ts +1 -1
  23. package/src/input/agent-workspace-basic-command-editor-submission.ts +1 -1
  24. package/src/input/agent-workspace-basic-command-editors.ts +1 -1
  25. package/src/input/agent-workspace-calendar-connect-editor.ts +1 -1
  26. package/src/input/agent-workspace-calendar-oauth-editor.ts +1 -1
  27. package/src/input/agent-workspace-calendar-subscribe-editor.ts +1 -1
  28. package/src/input/agent-workspace-command-editor-engine.ts +2 -2
  29. package/src/input/agent-workspace-direct-editor-submission.ts +1 -1
  30. package/src/input/agent-workspace-email-connect-editor.ts +1 -1
  31. package/src/input/agent-workspace-live-counters.ts +1 -1
  32. package/src/input/agent-workspace-settings.ts +1 -1
  33. package/src/input/agent-workspace-snapshot-builders.ts +2 -2
  34. package/src/input/agent-workspace-snapshot-config.ts +1 -1
  35. package/src/input/agent-workspace-snapshot.ts +9 -9
  36. package/src/input/agent-workspace-types.ts +4 -4
  37. package/src/input/agent-workspace.ts +1 -1
  38. package/src/input/commands/calendar-connect-runtime.ts +1 -1
  39. package/src/input/commands/calendar-subscription-runtime.ts +2 -2
  40. package/src/input/commands/operator-actions-runtime.ts +1 -1
  41. package/src/input/commands/session-content.ts +1 -1
  42. package/src/input/commands/session-workflow.ts +1 -1
  43. package/src/input/feed-context-factory.ts +4 -4
  44. package/src/input/handler-feed.ts +9 -9
  45. package/src/input/handler.ts +1 -1
  46. package/src/input/panel-paste-flood-guard.ts +3 -3
  47. package/src/input/settings-modal.ts +1 -1
  48. package/src/main.ts +28 -33
  49. package/src/renderer/agent-workspace-context-lines.ts +1 -1
  50. package/src/renderer/startup-theme-probe.ts +1 -1
  51. package/src/renderer/status-glyphs.ts +2 -2
  52. package/src/renderer/terminal-bg-probe.ts +1 -1
  53. package/src/renderer/terminal-escapes.ts +3 -2
  54. package/src/renderer/theme-mode-config.ts +2 -2
  55. package/src/renderer/theme.ts +4 -4
  56. package/src/renderer/thinking.ts +1 -1
  57. package/src/renderer/ui-factory.ts +1 -1
  58. package/src/renderer/ui-primitives.ts +2 -2
  59. package/src/runtime/bootstrap-core.ts +2 -2
  60. package/src/runtime/bootstrap.ts +3 -3
  61. package/src/runtime/calendar-boot-refresh.ts +1 -1
  62. package/src/runtime/lan-scan-consent.ts +1 -1
  63. package/src/runtime/services.ts +7 -7
  64. package/src/runtime/terminal-output-guard.ts +1 -1
  65. package/src/runtime/ui-services.ts +1 -1
  66. package/src/runtime/unhandled-rejection-guard.ts +41 -0
  67. package/src/shell/agent-workspace-fullscreen.ts +1 -1
  68. package/src/shell/terminal-focus-mode.ts +9 -9
  69. package/src/shell/ui-openers.ts +1 -1
  70. package/src/tools/agent-harness-operator-methods.ts +1 -1
  71. package/src/tools/agent-harness-personal-ops-discovery.ts +2 -2
  72. package/src/tools/agent-harness-personal-ops-lanes.ts +1 -1
  73. package/src/tools/agent-harness-personal-ops-types.ts +1 -1
  74. package/src/tools/agent-harness-prompt-context.ts +3 -3
  75. package/src/tools/agent-local-registry-memory.ts +1 -1
  76. package/src/tools/agent-operator-method-tool.ts +1 -1
  77. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-agent",
3
- "version": "1.5.7",
3
+ "version": "1.5.9",
4
4
  "private": false,
5
5
  "description": "GoodVibes personal operator assistant TUI with a proactive Agent product brain, isolated Agent Knowledge, local profiles, routines, skills, personas, and explicit build delegation.",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import type { MemoryRecord, MemoryRegistry, MemoryVectorStats } from '@pellux/goodvibes-sdk/platform/state';
2
- // W6-C2 (E6): the recall-honesty floor + eligibility receipt now live in the SDK as
2
+ // The recall-honesty floor + eligibility receipt now live in the SDK as
3
3
  // the ONE cross-surface contract (memory-recall-contract.ts) instead of being defined
4
4
  // per surface. This file re-exports them unchanged so every existing agent consumer
5
5
  // keeps importing from './memory-prompt.ts', while the SDK is the single source of the
@@ -32,9 +32,9 @@ function formatMemoryLine(record: MemoryRecord): string {
32
32
  }
33
33
 
34
34
  /**
35
- * Per-turn semantic ranking of an already-eligible memory set (Wave-4 W4-A1B).
35
+ * Per-turn semantic ranking of an already-eligible memory set.
36
36
  *
37
- * W4-A1 shipped eligibility (confidence + reviewState + provenance) as the hard trust
37
+ * Eligibility (confidence + reviewState + provenance) shipped as the hard trust
38
38
  * gate but had no per-turn query to rank WITHIN that eligible set — records were only
39
39
  * ever ordered by stored confidence/recency, regardless of whether they had anything to
40
40
  * do with what the user actually just asked. `rankMemoryForTurn` never touches the gate
@@ -103,7 +103,7 @@ export const OPERATOR_ACTIONS: Record<OperatorActionId, OperatorActionDescriptor
103
103
  pathTemplate: '/api/automation/jobs/{jobId}/run',
104
104
  targetField: 'jobId',
105
105
  },
106
- // W6-C3: automation.jobs.pause/resume were retired (redundant with
106
+ // automation.jobs.pause/resume were retired (redundant with
107
107
  // disable/enable — same {id,enabled} output, same semantics). The
108
108
  // user-facing "pause"/"resume" verb is unchanged; only the wire action +
109
109
  // path moved to the canonical disable/enable methods.
@@ -140,7 +140,7 @@ function receiptSegment(input: Omit<PromptContextReceiptSegment, 'approxTokens'>
140
140
 
141
141
  function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput): readonly PromptContextReceiptSegment[] {
142
142
  const vibe = discoverVibeFiles(input.shellPaths);
143
- // W6-C2 (E6): the VIBE prompt is a PROJECTION of persona/constraint records, not a
143
+ // The VIBE prompt is a PROJECTION of persona/constraint records, not a
144
144
  // re-read of the files (discoverVibeFiles above stays for the file-discovery receipt).
145
145
  const vibePrompt = buildVibeProjectionPrompt(input.memoryRegistry) ?? '';
146
146
  const projectContext = discoverProjectContextFiles(input.shellPaths);
@@ -234,7 +234,7 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
234
234
  suppressedCount: suppressedMemory.length,
235
235
  promptChars: memoryPrompt.length,
236
236
  promptText: memoryPrompt,
237
- // Honest degrade note (Wave-4 W4-A1B): when per-turn relevance scoring did not
237
+ // Honest degrade note: when per-turn relevance scoring did not
238
238
  // run — no active-turn text, semantic index unavailable, or no vector match —
239
239
  // say so instead of silently presenting the fallback confidence/recency order as
240
240
  // if it were a relevance ranking.
@@ -245,7 +245,7 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
245
245
  class: record.cls,
246
246
  confidence: record.confidence,
247
247
  reason: describeMemoryPromptEligibility(record).reason,
248
- // Per-turn relevance (W4-A1B): honest wording, only present when actually scored.
248
+ // Per-turn relevance: honest wording, only present when actually scored.
249
249
  // F7a: the raw percent is paired with a qualitative band (see relevanceBand)
250
250
  // so a genuinely-lower-but-real score like "28%" doesn't read as noise.
251
251
  ...(memoryRanking.scored ? { relevance: `relevance to this turn: ${memoryRanking.relevanceById.get(record.id) ?? 0}% (${relevanceBand(memoryRanking.relevanceById.get(record.id) ?? 0)})` } : {}),
@@ -86,7 +86,7 @@ export interface SessionRecordSummary {
86
86
  }
87
87
 
88
88
  /**
89
- * Honest outcome discriminator. 'still_closed' is the Wave-1 heartbeat-resurrection
89
+ * Honest outcome discriminator. 'still_closed' is the heartbeat-resurrection
90
90
  * guard: a 200 that carries conflict:{status:'closed'} with reopened:false MUST NOT
91
91
  * be read as 'registered' or 'reopened'.
92
92
  */
@@ -5,7 +5,7 @@ import type { ShellPathService } from '@/runtime/index.ts';
5
5
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
6
6
  import { assertNoSecretLikeText } from './persona-registry.ts';
7
7
  import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-frontmatter.ts';
8
- // W6-C2 (E6): VIBE.md is now a PROJECTION of persona/constraint records in the
8
+ // VIBE.md is now a PROJECTION of persona/constraint records in the
9
9
  // canonical memory store, not a separate source of truth. renderVibeProjection emits
10
10
  // the same '## VIBE.md' block from those records (caveat preserved); the file is
11
11
  // demoted to an import/export FORMAT folded in via vibeBodyToConstraintOptions.
@@ -213,7 +213,7 @@ export function buildVibePrompt(shellPaths: AgentVibePaths): string | null {
213
213
  }
214
214
 
215
215
  /**
216
- * W6-C2 (E6): the VIBE.md prompt block as a PROJECTION of persona/constraint records.
216
+ * The VIBE.md prompt block as a PROJECTION of persona/constraint records.
217
217
  *
218
218
  * This is the store-sourced replacement for buildVibePrompt (which reads the file
219
219
  * directly). It renders the same '## GoodVibes Agent VIBE.md' block — including the
@@ -226,7 +226,7 @@ export function buildVibeProjectionPrompt(memoryRecords: { getAll(): readonly Me
226
226
  }
227
227
 
228
228
  /**
229
- * W6-C2 (E6): fold discovered VIBE.md files into the store as persona/constraint
229
+ * Fold discovered VIBE.md files into the store as persona/constraint
230
230
  * records — the file demoted to an IMPORT FORMAT. Each bullet becomes one record so a
231
231
  * later single-record edit changes exactly one projected line. Secret-like content is
232
232
  * already rejected by discoverVibeFiles (readVibeCandidate → assertNoSecretLikeText),
@@ -257,7 +257,7 @@ export async function importVibeFilesIntoMemory(
257
257
  }
258
258
 
259
259
  /**
260
- * W6-C2 (E6): the persisted marker that makes the VIBE.md → memory import a strictly
260
+ * The persisted marker that makes the VIBE.md → memory import a strictly
261
261
  * ONE-TIME migration. Keyed by absolute file path → content hash, so importing the same
262
262
  * VIBE.md twice is a no-op (re-import would create near-duplicate persona records), while
263
263
  * a NEW project's VIBE.md still migrates exactly once. Mirrors the sessions.spine-folded
@@ -300,7 +300,7 @@ function hashVibeBody(body: string): string {
300
300
  }
301
301
 
302
302
  /**
303
- * W6-C2 (E6): fold discovered VIBE.md files into persona/constraint records ONCE.
303
+ * Fold discovered VIBE.md files into persona/constraint records ONCE.
304
304
  * Guarded by a persisted path→hash marker so it never re-imports the same file (which
305
305
  * would create near-duplicate persona records). Called at boot AFTER memoryStore.init().
306
306
  * Returns the number of records created this run (0 when everything is already migrated).
@@ -15,6 +15,14 @@ export interface StreamingAudioPlayer {
15
15
  readonly available: boolean;
16
16
  play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
17
17
  stop(): void;
18
+ /**
19
+ * Resolves once the currently playing sink has finished naturally (its
20
+ * process closed) or after `timeoutMs`, whichever comes first; resolves
21
+ * immediately when nothing is playing. The exit path uses this to let the
22
+ * audio the user is already hearing finish inside a short bounded window
23
+ * instead of killing it mid-drain. stop() remains the instant cut.
24
+ */
25
+ waitForDrain(timeoutMs: number): Promise<void>;
18
26
  }
19
27
 
20
28
  export interface StreamingAudioPlaybackOptions {
@@ -25,6 +33,8 @@ export interface StreamingAudioPlaybackOptions {
25
33
  interface SpawnProcess {
26
34
  readonly stdin: Writable;
27
35
  once(event: 'close', listener: () => void): this;
36
+ once(event: 'spawn', listener: () => void): this;
37
+ once(event: 'error', listener: (error: unknown) => void): this;
28
38
  kill(signal?: NodeJS.Signals | number): boolean;
29
39
  }
30
40
  type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
@@ -32,6 +42,13 @@ type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnPr
32
42
  export interface LocalStreamingAudioPlayerOptions {
33
43
  readonly env?: NodeJS.ProcessEnv;
34
44
  readonly spawnProcess?: SpawnProcessFactory;
45
+ /**
46
+ * Injected player command — the test seam companion to `spawnProcess`.
47
+ * When set, the constructor uses it verbatim instead of scanning PATH, so
48
+ * deterministic fake-sink tests never depend on mpv/ffplay being installed
49
+ * on the machine running them. Pass null to model "no player found".
50
+ */
51
+ readonly command?: StreamingAudioPlayerCommand | null;
35
52
  }
36
53
 
37
54
  export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
@@ -40,7 +57,9 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
40
57
  private readonly spawnProcess: SpawnProcessFactory;
41
58
 
42
59
  constructor(options: LocalStreamingAudioPlayerOptions = {}) {
43
- this.command = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
60
+ this.command = options.command !== undefined
61
+ ? options.command
62
+ : resolveStreamingAudioPlayerCommand(options.env ?? process.env);
44
63
  this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
45
64
  }
46
65
 
@@ -67,12 +86,27 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
67
86
  options.signal?.addEventListener('abort', abort, { once: true });
68
87
 
69
88
  try {
89
+ // Head survival: hold the first audio byte until the sink has actually
90
+ // started (its 'spawn' event) instead of writing into a process that has
91
+ // not exec'd yet. Writing before the player is up is the spawn race that
92
+ // clipped the beginning of playback. A spawn failure rejects here so the
93
+ // caller can report it honestly rather than swallowing a dead player.
94
+ await awaitReady(proc, options.signal);
95
+ if (options.signal?.aborted) return;
96
+
70
97
  for await (const chunk of chunks) {
71
98
  if (options.signal?.aborted) break;
72
99
  if (chunk.data.byteLength === 0) continue;
73
100
  await writeStdin(proc, chunk.data);
74
101
  }
75
- proc.stdin.end();
102
+
103
+ // An intentional interrupt (turn cancel / quit chord / /tts stop) has
104
+ // already torn the process down via `abort` and must cut immediately —
105
+ // do not wait on a graceful drain. A natural end-of-speech, by contrast,
106
+ // closes stdin and waits for the sink to play out every buffered sample
107
+ // so the tail of the response is never truncated.
108
+ if (options.signal?.aborted) return;
109
+ try { proc.stdin.end(); } catch { /* ignore */ }
76
110
  await waitForExit(proc);
77
111
  } finally {
78
112
  options.signal?.removeEventListener('abort', abort);
@@ -87,14 +121,33 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
87
121
  try { proc.stdin.destroy(); } catch { /* ignore */ }
88
122
  try { proc.kill('SIGTERM'); } catch { /* ignore */ }
89
123
  }
124
+
125
+ waitForDrain(timeoutMs: number): Promise<void> {
126
+ const proc = this.activeProcess;
127
+ if (!proc) return Promise.resolve();
128
+ return new Promise((resolve) => {
129
+ let settled = false;
130
+ const settle = () => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ resolve();
135
+ };
136
+ const timer = setTimeout(settle, timeoutMs);
137
+ proc.once('close', settle);
138
+ });
139
+ }
90
140
  }
91
141
 
92
142
  export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
93
143
  const mpv = findExecutable('mpv', env);
94
144
  if (mpv) {
145
+ // No --cache=no: mpv's read-ahead cache buffers the incoming pipe so the
146
+ // opening audio survives device-open latency and network jitter instead of
147
+ // underrunning while the output device is still spinning up.
95
148
  return {
96
149
  command: mpv,
97
- args: ['--no-terminal', '--really-quiet', '--force-window=no', '--cache=no', '-'],
150
+ args: ['--no-terminal', '--really-quiet', '--force-window=no', '-'],
98
151
  label: 'mpv',
99
152
  };
100
153
  }
@@ -102,18 +155,51 @@ export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = proc
102
155
  if (ffplay) {
103
156
  return {
104
157
  command: ffplay,
105
- args: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
158
+ args: FFPLAY_BASE_ARGS,
106
159
  label: 'ffplay',
107
160
  };
108
161
  }
109
162
  return null;
110
163
  }
111
164
 
165
+ // ffplay -autoexit quits as soon as its input ends, before the audio output
166
+ // buffer has drained — that clips the tail of the response. `apad` appends a
167
+ // short run of silence so the real audio is fully played out and only the
168
+ // trailing silence gets trimmed.
169
+ const FFPLAY_APAD = ['-af', 'apad=pad_dur=0.3'] as const;
170
+ const FFPLAY_BASE_ARGS = ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-i', 'pipe:0'] as const;
171
+
112
172
  function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
113
173
  if (command.label !== 'ffplay' || !format) return command.args;
114
174
  const normalized = format.trim().toLowerCase();
115
175
  if (!normalized || normalized.includes('/')) return command.args;
116
- return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
176
+ return ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-f', normalized, '-i', 'pipe:0'];
177
+ }
178
+
179
+ /**
180
+ * awaitReady — resolves once the spawned player has actually started (its
181
+ * 'spawn' event), rejects if it fails to start ('error'), and resolves early
182
+ * if the caller aborts during startup so an intentional interrupt is never
183
+ * blocked. This is the readiness gate that keeps the first audio byte from
184
+ * being written into a not-yet-running sink.
185
+ */
186
+ function awaitReady(proc: SpawnProcess, signal?: AbortSignal): Promise<void> {
187
+ if (signal?.aborted) return Promise.resolve();
188
+ return new Promise<void>((resolve, reject) => {
189
+ let settled = false;
190
+ const settle = (action: () => void) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ signal?.removeEventListener('abort', onAbort);
194
+ action();
195
+ };
196
+ const onSpawn = () => settle(resolve);
197
+ const onError = (error: unknown) => settle(() => reject(error instanceof Error ? error : new Error(String(error))));
198
+ const onAbort = () => settle(resolve);
199
+ proc.once('spawn', onSpawn);
200
+ proc.once('error', onError);
201
+ signal?.addEventListener('abort', onAbort, { once: true });
202
+ });
117
203
  }
118
204
 
119
205
  function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {