@sudocode-ai/local-server 0.1.16 → 0.1.18-dev.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.
Files changed (111) hide show
  1. package/dist/better-sqlite3-loader.d.ts +9 -0
  2. package/dist/better-sqlite3-loader.d.ts.map +1 -0
  3. package/dist/better-sqlite3-loader.js +24 -0
  4. package/dist/better-sqlite3-loader.js.map +1 -0
  5. package/dist/execution/executors/agent-executor-wrapper.d.ts +6 -0
  6. package/dist/execution/executors/agent-executor-wrapper.d.ts.map +1 -1
  7. package/dist/execution/executors/agent-executor-wrapper.js +75 -4
  8. package/dist/execution/executors/agent-executor-wrapper.js.map +1 -1
  9. package/dist/execution/executors/executor-factory.d.ts +9 -6
  10. package/dist/execution/executors/executor-factory.d.ts.map +1 -1
  11. package/dist/execution/executors/executor-factory.js +6 -5
  12. package/dist/execution/executors/executor-factory.js.map +1 -1
  13. package/dist/execution/worktree/config.js +1 -1
  14. package/dist/execution/worktree/config.js.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/public/assets/index-B1p5HV93.css +1 -0
  19. package/dist/public/assets/index-qqIsBBjJ.js +3836 -0
  20. package/dist/public/assets/index-qqIsBBjJ.js.map +1 -0
  21. package/dist/public/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  22. package/dist/public/index.html +2 -2
  23. package/dist/public/kokoro-test.html +197 -0
  24. package/dist/routes/config.d.ts.map +1 -1
  25. package/dist/routes/config.js +39 -0
  26. package/dist/routes/config.js.map +1 -1
  27. package/dist/routes/executions.d.ts.map +1 -1
  28. package/dist/routes/executions.js +9 -0
  29. package/dist/routes/executions.js.map +1 -1
  30. package/dist/routes/voice.d.ts +12 -0
  31. package/dist/routes/voice.d.ts.map +1 -0
  32. package/dist/routes/voice.js +387 -0
  33. package/dist/routes/voice.js.map +1 -0
  34. package/dist/routes/workflows.d.ts.map +1 -1
  35. package/dist/routes/workflows.js +2 -1
  36. package/dist/routes/workflows.js.map +1 -1
  37. package/dist/services/db.d.ts +1 -1
  38. package/dist/services/db.d.ts.map +1 -1
  39. package/dist/services/db.js +2 -2
  40. package/dist/services/db.js.map +1 -1
  41. package/dist/services/execution-service.d.ts +8 -0
  42. package/dist/services/execution-service.d.ts.map +1 -1
  43. package/dist/services/execution-service.js +27 -5
  44. package/dist/services/execution-service.js.map +1 -1
  45. package/dist/services/narration-service.d.ts +304 -0
  46. package/dist/services/narration-service.d.ts.map +1 -0
  47. package/dist/services/narration-service.js +729 -0
  48. package/dist/services/narration-service.js.map +1 -0
  49. package/dist/services/stt-providers/index.d.ts +21 -0
  50. package/dist/services/stt-providers/index.d.ts.map +1 -0
  51. package/dist/services/stt-providers/index.js +32 -0
  52. package/dist/services/stt-providers/index.js.map +1 -0
  53. package/dist/services/stt-providers/openai-whisper.d.ts +66 -0
  54. package/dist/services/stt-providers/openai-whisper.d.ts.map +1 -0
  55. package/dist/services/stt-providers/openai-whisper.js +137 -0
  56. package/dist/services/stt-providers/openai-whisper.js.map +1 -0
  57. package/dist/services/stt-providers/whisper-local.d.ts +64 -0
  58. package/dist/services/stt-providers/whisper-local.d.ts.map +1 -0
  59. package/dist/services/stt-providers/whisper-local.js +166 -0
  60. package/dist/services/stt-providers/whisper-local.js.map +1 -0
  61. package/dist/services/stt-service.d.ts +160 -0
  62. package/dist/services/stt-service.d.ts.map +1 -0
  63. package/dist/services/stt-service.js +246 -0
  64. package/dist/services/stt-service.js.map +1 -0
  65. package/dist/services/tts-providers/browser-tts.d.ts +64 -0
  66. package/dist/services/tts-providers/browser-tts.d.ts.map +1 -0
  67. package/dist/services/tts-providers/browser-tts.js +89 -0
  68. package/dist/services/tts-providers/browser-tts.js.map +1 -0
  69. package/dist/services/tts-providers/index.d.ts +20 -0
  70. package/dist/services/tts-providers/index.d.ts.map +1 -0
  71. package/dist/services/tts-providers/index.js +31 -0
  72. package/dist/services/tts-providers/index.js.map +1 -0
  73. package/dist/services/tts-service.d.ts +190 -0
  74. package/dist/services/tts-service.d.ts.map +1 -0
  75. package/dist/services/tts-service.js +296 -0
  76. package/dist/services/tts-service.js.map +1 -0
  77. package/dist/services/tts-sidecar-manager.d.ts +276 -0
  78. package/dist/services/tts-sidecar-manager.d.ts.map +1 -0
  79. package/dist/services/tts-sidecar-manager.js +665 -0
  80. package/dist/services/tts-sidecar-manager.js.map +1 -0
  81. package/dist/services/websocket.d.ts +31 -1
  82. package/dist/services/websocket.d.ts.map +1 -1
  83. package/dist/services/websocket.js +149 -0
  84. package/dist/services/websocket.js.map +1 -1
  85. package/dist/services/worktree-sync-service.d.ts +17 -2
  86. package/dist/services/worktree-sync-service.d.ts.map +1 -1
  87. package/dist/services/worktree-sync-service.js +103 -6
  88. package/dist/services/worktree-sync-service.js.map +1 -1
  89. package/dist/utils/voice-config.d.ts +26 -0
  90. package/dist/utils/voice-config.d.ts.map +1 -0
  91. package/dist/utils/voice-config.js +48 -0
  92. package/dist/utils/voice-config.js.map +1 -0
  93. package/dist/workers/execution-worker.js +12 -4
  94. package/dist/workers/execution-worker.js.map +1 -1
  95. package/dist/workflow/base-workflow-engine.d.ts +3 -1
  96. package/dist/workflow/base-workflow-engine.d.ts.map +1 -1
  97. package/dist/workflow/base-workflow-engine.js.map +1 -1
  98. package/dist/workflow/engines/orchestrator-engine.d.ts +5 -1
  99. package/dist/workflow/engines/orchestrator-engine.d.ts.map +1 -1
  100. package/dist/workflow/engines/orchestrator-engine.js +4 -1
  101. package/dist/workflow/engines/orchestrator-engine.js.map +1 -1
  102. package/dist/workflow/engines/sequential-engine.d.ts +9 -2
  103. package/dist/workflow/engines/sequential-engine.d.ts.map +1 -1
  104. package/dist/workflow/engines/sequential-engine.js +102 -22
  105. package/dist/workflow/engines/sequential-engine.js.map +1 -1
  106. package/dist/workflow/workflow-engine.d.ts +8 -1
  107. package/dist/workflow/workflow-engine.d.ts.map +1 -1
  108. package/package.json +13 -4
  109. package/dist/public/assets/index-D4AKx6EO.css +0 -1
  110. package/dist/public/assets/index-DorQqwGV.js +0 -927
  111. package/dist/public/assets/index-DorQqwGV.js.map +0 -1
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Narration Service
3
+ *
4
+ * Converts execution events (NormalizedEntry) into spoken narration text.
5
+ * Summarizes agent actions for voice feedback during execution.
6
+ *
7
+ * @module services/narration-service
8
+ */
9
+ /**
10
+ * Default narration configuration
11
+ */
12
+ const DEFAULT_CONFIG = {
13
+ enabled: false,
14
+ // 1000 chars allows most messages to be read in full
15
+ // TTS handles longer text fine, and users expect full messages
16
+ maxAssistantMessageLength: 1000,
17
+ maxCommandLength: 50,
18
+ includeFilePaths: true,
19
+ narrateToolResults: false,
20
+ narrateToolUse: true,
21
+ narrateAssistantMessages: true,
22
+ };
23
+ /**
24
+ * NarrationService
25
+ *
26
+ * Transforms NormalizedEntry execution events into human-readable narration
27
+ * suitable for text-to-speech output.
28
+ *
29
+ * Narration rules:
30
+ * | Event | Narration |
31
+ * |-------|-----------|
32
+ * | tool_use: Read | "Reading [filename]" |
33
+ * | tool_use: Edit | "Editing [filename]" |
34
+ * | tool_use: Write | "Writing [filename]" |
35
+ * | tool_use: Bash | "Running [command summary]" |
36
+ * | tool_use: Grep | "Searching for [pattern]" |
37
+ * | tool_use: Glob | "Finding files matching [pattern]" |
38
+ * | assistant (short) | Speak directly |
39
+ * | assistant (long) | Summarize to ~2 sentences |
40
+ * | error | "Error: [summary]" |
41
+ * | result | "Done. [summary]" |
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const service = new NarrationService();
46
+ *
47
+ * for await (const entry of executionStream) {
48
+ * const narration = service.summarizeForVoice(entry);
49
+ * if (narration) {
50
+ * // Emit voice narration event
51
+ * emit({
52
+ * type: 'voice_narration',
53
+ * executionId: 'exec-123',
54
+ * ...narration
55
+ * });
56
+ * }
57
+ * }
58
+ * ```
59
+ */
60
+ export class NarrationService {
61
+ config;
62
+ constructor(config) {
63
+ this.config = { ...DEFAULT_CONFIG, ...config };
64
+ }
65
+ /**
66
+ * Convert an execution event to a voice narration event
67
+ *
68
+ * @param entry - The normalized entry from agent execution
69
+ * @returns NarrationResult if the event should be narrated, null otherwise
70
+ */
71
+ summarizeForVoice(entry) {
72
+ switch (entry.type.kind) {
73
+ case "tool_use":
74
+ return this.describeToolUse(entry);
75
+ case "assistant_message":
76
+ // Skip if assistant message narration is disabled
77
+ if (!this.config.narrateAssistantMessages) {
78
+ return null;
79
+ }
80
+ return this.summarizeAssistantMessage(entry.content);
81
+ case "error":
82
+ return this.summarizeError(entry);
83
+ case "thinking":
84
+ // Skip thinking events - internal reasoning shouldn't be narrated
85
+ return null;
86
+ case "system_message":
87
+ // Skip system messages - they're not user-facing
88
+ return null;
89
+ case "user_message":
90
+ // Skip user messages - the user knows what they said
91
+ return null;
92
+ default:
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Create a full VoiceNarrationEvent with execution ID
98
+ *
99
+ * @param entry - The normalized entry from agent execution
100
+ * @param executionId - The execution ID to associate with the event
101
+ * @returns VoiceNarrationEvent if the event should be narrated, null otherwise
102
+ */
103
+ createNarrationEvent(entry, executionId) {
104
+ const result = this.summarizeForVoice(entry);
105
+ if (!result)
106
+ return null;
107
+ return {
108
+ type: "voice_narration",
109
+ executionId,
110
+ text: result.text,
111
+ category: result.category,
112
+ priority: result.priority,
113
+ };
114
+ }
115
+ /**
116
+ * Describe a tool use event in natural language
117
+ */
118
+ describeToolUse(entry) {
119
+ if (entry.type.kind !== "tool_use")
120
+ return null;
121
+ const tool = entry.type.tool;
122
+ const toolName = tool.toolName.toLowerCase();
123
+ // If narrateToolUse is disabled, only allow 'speak' tool through
124
+ if (!this.config.narrateToolUse && toolName !== "speak") {
125
+ return null;
126
+ }
127
+ // Only narrate the start of tool execution (running status)
128
+ // or completed tools if configured to narrate results
129
+ if (tool.status !== "running" && !this.config.narrateToolResults) {
130
+ return null;
131
+ }
132
+ // If tool completed and we should narrate results
133
+ if (this.config.narrateToolResults &&
134
+ (tool.status === "success" || tool.status === "failed")) {
135
+ return this.describeToolResult(tool);
136
+ }
137
+ // Special handling for 'speak' tool - extract text and priority from args
138
+ if (toolName === "speak") {
139
+ const args = tool.action.kind === "tool"
140
+ ? tool.action.args
141
+ : {};
142
+ const text = args.text;
143
+ if (!text)
144
+ return null;
145
+ const priority = args.priority || "normal";
146
+ return {
147
+ text,
148
+ category: "status",
149
+ priority,
150
+ };
151
+ }
152
+ // Describe the tool action starting
153
+ const text = this.describeToolAction(tool.toolName, tool.action);
154
+ if (!text)
155
+ return null;
156
+ return {
157
+ text,
158
+ category: "progress",
159
+ priority: "normal",
160
+ };
161
+ }
162
+ /**
163
+ * Generate narration text for a specific tool action
164
+ */
165
+ describeToolAction(toolName, action) {
166
+ switch (action.kind) {
167
+ case "file_read":
168
+ return `Reading ${this.formatPath(action.path)}`;
169
+ case "file_write":
170
+ return `Writing ${this.formatPath(action.path)}`;
171
+ case "file_edit":
172
+ return `Editing ${this.formatPath(action.path)}`;
173
+ case "command_run":
174
+ return `Running ${this.formatCommand(action.command)}`;
175
+ case "search":
176
+ return `Searching for ${this.truncate(action.query, 30)}`;
177
+ case "tool":
178
+ // Generic tool - describe based on tool name
179
+ return this.describeGenericTool(action.toolName, action.args || {});
180
+ default:
181
+ // Unknown action type - use tool name
182
+ return `Using ${toolName}`;
183
+ }
184
+ }
185
+ /**
186
+ * Describe a generic tool use
187
+ */
188
+ describeGenericTool(toolName, args) {
189
+ const normalizedName = toolName.toLowerCase();
190
+ // Handle common tool names
191
+ switch (normalizedName) {
192
+ case "read":
193
+ if (args.file_path || args.path) {
194
+ return `Reading ${this.formatPath(String(args.file_path || args.path))}`;
195
+ }
196
+ return "Reading a file";
197
+ case "write":
198
+ if (args.file_path || args.path) {
199
+ return `Writing ${this.formatPath(String(args.file_path || args.path))}`;
200
+ }
201
+ return "Writing a file";
202
+ case "edit":
203
+ if (args.file_path || args.path) {
204
+ return `Editing ${this.formatPath(String(args.file_path || args.path))}`;
205
+ }
206
+ return "Editing a file";
207
+ case "bash":
208
+ if (args.command) {
209
+ return `Running ${this.formatCommand(String(args.command))}`;
210
+ }
211
+ return "Running a command";
212
+ case "grep":
213
+ if (args.pattern) {
214
+ return `Searching for ${this.truncate(String(args.pattern), 30)}`;
215
+ }
216
+ return "Searching code";
217
+ case "glob":
218
+ if (args.pattern) {
219
+ return `Finding files matching ${this.truncate(String(args.pattern), 30)}`;
220
+ }
221
+ return "Finding files";
222
+ case "task":
223
+ return "Starting a background task";
224
+ case "webfetch":
225
+ case "web_fetch":
226
+ if (args.url) {
227
+ return `Fetching ${this.formatUrl(String(args.url))}`;
228
+ }
229
+ return "Fetching from the web";
230
+ case "websearch":
231
+ case "web_search":
232
+ if (args.query) {
233
+ return `Searching the web for ${this.truncate(String(args.query), 30)}`;
234
+ }
235
+ return "Searching the web";
236
+ case "speak":
237
+ // Agent explicitly wants to speak this text - return as-is
238
+ if (args.text) {
239
+ return String(args.text);
240
+ }
241
+ return null;
242
+ default:
243
+ return `Using ${toolName}`;
244
+ }
245
+ }
246
+ /**
247
+ * Describe a tool result (for when narrateToolResults is enabled)
248
+ */
249
+ describeToolResult(tool) {
250
+ if (tool.status === "failed" || !tool.result?.success) {
251
+ const errorMsg = tool.result?.error || "unknown error";
252
+ return {
253
+ text: `${tool.toolName} failed: ${this.truncate(errorMsg, 50)}`,
254
+ category: "error",
255
+ priority: "high",
256
+ };
257
+ }
258
+ return {
259
+ text: `${tool.toolName} completed successfully`,
260
+ category: "progress",
261
+ priority: "low",
262
+ };
263
+ }
264
+ /**
265
+ * Summarize an assistant message for voice narration
266
+ */
267
+ summarizeAssistantMessage(content) {
268
+ if (!content || content.trim().length === 0) {
269
+ return null;
270
+ }
271
+ const trimmed = content.trim();
272
+ // If message is short enough, use it directly
273
+ if (trimmed.length <= this.config.maxAssistantMessageLength) {
274
+ return {
275
+ text: trimmed,
276
+ category: "status",
277
+ priority: "normal",
278
+ };
279
+ }
280
+ // For longer messages, extract key sentences
281
+ const summarized = this.extractKeySentences(trimmed);
282
+ return {
283
+ text: summarized,
284
+ category: "status",
285
+ priority: "normal",
286
+ };
287
+ }
288
+ /**
289
+ * Extract key sentences from a longer text
290
+ *
291
+ * Strategy:
292
+ * 1. Split into sentences
293
+ * 2. Take first 1-2 sentences that are meaningful
294
+ * 3. Skip sentences that are just formatting or headers
295
+ */
296
+ extractKeySentences(text) {
297
+ // Split on sentence boundaries
298
+ const sentencePattern = /[.!?]+\s+|[\n\r]+/;
299
+ const sentences = text.split(sentencePattern).filter((s) => {
300
+ const trimmed = s.trim();
301
+ // Skip empty sentences, markdown headers, code blocks, etc.
302
+ return (trimmed.length > 10 &&
303
+ !trimmed.startsWith("#") &&
304
+ !trimmed.startsWith("```") &&
305
+ !trimmed.startsWith("|") && // tables
306
+ !trimmed.match(/^[-*]\s/) // bullet points
307
+ );
308
+ });
309
+ if (sentences.length === 0) {
310
+ // Fall back to truncating the original text
311
+ return this.truncate(text, this.config.maxAssistantMessageLength);
312
+ }
313
+ // Take first 1-2 sentences
314
+ const firstSentence = sentences[0].trim();
315
+ if (sentences.length === 1 || firstSentence.length > 80) {
316
+ return this.truncate(firstSentence, this.config.maxAssistantMessageLength);
317
+ }
318
+ const secondSentence = sentences[1]?.trim();
319
+ if (secondSentence) {
320
+ const combined = `${firstSentence}. ${secondSentence}`;
321
+ if (combined.length <= this.config.maxAssistantMessageLength + 20) {
322
+ return combined;
323
+ }
324
+ }
325
+ return firstSentence;
326
+ }
327
+ /**
328
+ * Summarize an error event
329
+ */
330
+ summarizeError(entry) {
331
+ if (entry.type.kind !== "error") {
332
+ return {
333
+ text: "An error occurred",
334
+ category: "error",
335
+ priority: "high",
336
+ };
337
+ }
338
+ const error = entry.type.error;
339
+ const message = error.message || "An unknown error occurred";
340
+ return {
341
+ text: `Error: ${this.truncate(message, 80)}`,
342
+ category: "error",
343
+ priority: "high",
344
+ };
345
+ }
346
+ /**
347
+ * Format a file path for narration
348
+ *
349
+ * Extracts just the filename or last path component for brevity.
350
+ */
351
+ formatPath(path) {
352
+ if (!this.config.includeFilePaths) {
353
+ return "a file";
354
+ }
355
+ // Extract filename from path
356
+ const parts = path.split(/[/\\]/);
357
+ const filename = parts[parts.length - 1];
358
+ // If path has directory context, include parent
359
+ if (parts.length > 1) {
360
+ const parent = parts[parts.length - 2];
361
+ if (parent && parent !== "." && parent !== "..") {
362
+ return `${parent}/${filename}`;
363
+ }
364
+ }
365
+ return filename;
366
+ }
367
+ /**
368
+ * Format a command for narration
369
+ *
370
+ * Truncates long commands and extracts the main command.
371
+ */
372
+ formatCommand(command) {
373
+ const trimmed = command.trim();
374
+ // Extract first part (the actual command)
375
+ const firstWord = trimmed.split(/\s+/)[0];
376
+ // For common commands, provide more context
377
+ const commonCommands = ["npm", "yarn", "pnpm", "git", "docker", "make"];
378
+ if (commonCommands.includes(firstWord)) {
379
+ // Include the subcommand for these
380
+ const parts = trimmed.split(/\s+/);
381
+ if (parts.length >= 2) {
382
+ const subcommand = `${parts[0]} ${parts[1]}`;
383
+ if (subcommand.length <= this.config.maxCommandLength) {
384
+ return subcommand;
385
+ }
386
+ }
387
+ }
388
+ return this.truncate(trimmed, this.config.maxCommandLength);
389
+ }
390
+ /**
391
+ * Format a URL for narration
392
+ */
393
+ formatUrl(url) {
394
+ try {
395
+ const parsed = new URL(url);
396
+ // Return just the hostname
397
+ return parsed.hostname;
398
+ }
399
+ catch {
400
+ return this.truncate(url, 30);
401
+ }
402
+ }
403
+ /**
404
+ * Truncate text to a maximum length
405
+ */
406
+ truncate(text, maxLength) {
407
+ if (text.length <= maxLength) {
408
+ return text;
409
+ }
410
+ return text.slice(0, maxLength - 3) + "...";
411
+ }
412
+ /**
413
+ * Update the narration configuration
414
+ */
415
+ updateConfig(config) {
416
+ this.config = { ...this.config, ...config };
417
+ }
418
+ /**
419
+ * Get the current configuration
420
+ */
421
+ getConfig() {
422
+ return { ...this.config };
423
+ }
424
+ }
425
+ /**
426
+ * Default rate limiter configuration
427
+ */
428
+ const DEFAULT_RATE_LIMITER_CONFIG = {
429
+ // Low interval - client handles TTS queuing, server just prevents rapid-fire bursts
430
+ minIntervalMs: 100,
431
+ maxQueueSize: 10,
432
+ coalesceToolCalls: true,
433
+ coalesceWindowMs: 500,
434
+ };
435
+ /**
436
+ * NarrationRateLimiter
437
+ *
438
+ * Limits narration event emissions to prevent overwhelming the TTS system.
439
+ *
440
+ * Rate limiting rules:
441
+ * - Don't emit narration more than once per second
442
+ * - Coalesce rapid tool calls into summary ("Reading 3 files...")
443
+ * - Skip low-priority narrations if queue is building up
444
+ *
445
+ * @example
446
+ * ```typescript
447
+ * const limiter = new NarrationRateLimiter();
448
+ *
449
+ * for await (const entry of executionStream) {
450
+ * const narration = narrationService.summarizeForVoice(entry);
451
+ * if (narration) {
452
+ * const result = limiter.submit(narration);
453
+ * if (result) {
454
+ * // Emit the narration event
455
+ * broadcastVoiceNarration(projectId, executionId, result);
456
+ * }
457
+ * }
458
+ * }
459
+ *
460
+ * // At end of execution, flush any pending narrations
461
+ * const final = limiter.flush();
462
+ * if (final) {
463
+ * broadcastVoiceNarration(projectId, executionId, final);
464
+ * }
465
+ * ```
466
+ */
467
+ export class NarrationRateLimiter {
468
+ config;
469
+ lastEmitTime = 0;
470
+ pendingQueue = [];
471
+ coalescingToolCalls = [];
472
+ lastToolCallTime = 0;
473
+ constructor(config) {
474
+ this.config = { ...DEFAULT_RATE_LIMITER_CONFIG, ...config };
475
+ }
476
+ /**
477
+ * Submit a narration for potential emission
478
+ *
479
+ * Returns the narration to emit immediately, or null if rate limited.
480
+ * The narration may be modified (e.g., coalesced tool calls).
481
+ *
482
+ * @param narration - The narration result to submit
483
+ * @returns The narration to emit, or null if rate limited
484
+ */
485
+ submit(narration) {
486
+ const now = Date.now();
487
+ // Check if this is a tool call that should be coalesced
488
+ if (this.config.coalesceToolCalls && narration.category === "progress") {
489
+ const coalesced = this.tryCoalesceToolCall(narration, now);
490
+ if (coalesced === "pending") {
491
+ // Tool call was added to coalescing queue, nothing to emit yet
492
+ return null;
493
+ }
494
+ else if (coalesced) {
495
+ // Coalesced result ready to emit
496
+ narration = coalesced;
497
+ }
498
+ }
499
+ // Check rate limit
500
+ const timeSinceLastEmit = now - this.lastEmitTime;
501
+ if (timeSinceLastEmit < this.config.minIntervalMs) {
502
+ // Rate limited - queue or skip based on priority
503
+ return this.handleRateLimited(narration, now);
504
+ }
505
+ // Can emit now - but first check if we have pending high-priority items
506
+ const pending = this.popHighestPriority();
507
+ if (pending) {
508
+ // Emit pending item first, queue current
509
+ this.pendingQueue.push({ narration, timestamp: now });
510
+ this.lastEmitTime = now;
511
+ return pending;
512
+ }
513
+ // Emit current narration
514
+ this.lastEmitTime = now;
515
+ return narration;
516
+ }
517
+ /**
518
+ * Flush any pending narrations
519
+ *
520
+ * Call this at the end of an execution to emit any remaining narrations.
521
+ *
522
+ * @returns The highest priority pending narration, or null if none
523
+ */
524
+ flush() {
525
+ // First, flush any coalescing tool calls
526
+ const coalescedResult = this.flushCoalescing();
527
+ if (coalescedResult) {
528
+ return coalescedResult;
529
+ }
530
+ // Then return highest priority pending item
531
+ return this.popHighestPriority();
532
+ }
533
+ /**
534
+ * Check if there are any pending narrations
535
+ */
536
+ hasPending() {
537
+ return (this.pendingQueue.length > 0 || this.coalescingToolCalls.length > 0);
538
+ }
539
+ /**
540
+ * Reset the rate limiter state
541
+ */
542
+ reset() {
543
+ this.lastEmitTime = 0;
544
+ this.pendingQueue = [];
545
+ this.coalescingToolCalls = [];
546
+ this.lastToolCallTime = 0;
547
+ }
548
+ /**
549
+ * Try to coalesce tool calls into a summary
550
+ *
551
+ * @returns "pending" if added to queue, NarrationResult if ready to emit, null if not a coalesceable call
552
+ */
553
+ tryCoalesceToolCall(narration, now) {
554
+ const text = narration.text;
555
+ // Check if this is a file/search operation that can be coalesced
556
+ const fileReadMatch = text.match(/^Reading\s+(.+)$/);
557
+ const fileEditMatch = text.match(/^Editing\s+(.+)$/);
558
+ const searchMatch = text.match(/^Searching\s+(.+)$/);
559
+ let action = null;
560
+ if (fileReadMatch)
561
+ action = "Reading";
562
+ else if (fileEditMatch)
563
+ action = "Editing";
564
+ else if (searchMatch)
565
+ action = "Searching";
566
+ if (!action) {
567
+ // Not a coalesceable tool call, flush any pending and return null
568
+ return this.flushCoalescing();
569
+ }
570
+ // Check if within coalescing window
571
+ if (this.coalescingToolCalls.length > 0 &&
572
+ now - this.lastToolCallTime > this.config.coalesceWindowMs) {
573
+ // Window expired, flush previous and start new
574
+ const flushed = this.flushCoalescing();
575
+ this.coalescingToolCalls = [{ action, count: 1 }];
576
+ this.lastToolCallTime = now;
577
+ if (flushed) {
578
+ // Return flushed result, current is queued
579
+ return flushed;
580
+ }
581
+ return "pending";
582
+ }
583
+ // Add to coalescing queue
584
+ const existing = this.coalescingToolCalls.find((c) => c.action === action);
585
+ if (existing) {
586
+ existing.count++;
587
+ }
588
+ else {
589
+ this.coalescingToolCalls.push({ action, count: 1 });
590
+ }
591
+ this.lastToolCallTime = now;
592
+ return "pending";
593
+ }
594
+ /**
595
+ * Flush coalescing queue into a summary narration
596
+ */
597
+ flushCoalescing() {
598
+ if (this.coalescingToolCalls.length === 0) {
599
+ return null;
600
+ }
601
+ const calls = this.coalescingToolCalls;
602
+ this.coalescingToolCalls = [];
603
+ this.lastToolCallTime = 0;
604
+ // Single action
605
+ if (calls.length === 1) {
606
+ const { action, count } = calls[0];
607
+ if (count === 1) {
608
+ // Just one call, no need to summarize
609
+ return null;
610
+ }
611
+ const fileWord = count === 1 ? "file" : "files";
612
+ return {
613
+ text: `${action} ${count} ${fileWord}`,
614
+ category: "progress",
615
+ priority: "normal",
616
+ };
617
+ }
618
+ // Multiple actions - create combined summary
619
+ const parts = calls.map(({ action, count }) => {
620
+ const fileWord = count === 1 ? "file" : "files";
621
+ return `${action.toLowerCase()} ${count} ${fileWord}`;
622
+ });
623
+ return {
624
+ text: parts.join(", "),
625
+ category: "progress",
626
+ priority: "normal",
627
+ };
628
+ }
629
+ /**
630
+ * Handle a rate-limited narration
631
+ *
632
+ * Queues high priority narrations, skips low priority if queue is full.
633
+ */
634
+ handleRateLimited(narration, now) {
635
+ // Skip low priority if queue is building up
636
+ if (narration.priority === "low" &&
637
+ this.pendingQueue.length >= this.config.maxQueueSize) {
638
+ return null;
639
+ }
640
+ // Queue the narration
641
+ this.pendingQueue.push({ narration, timestamp: now });
642
+ // Prune old low-priority items if queue is too large
643
+ while (this.pendingQueue.length > this.config.maxQueueSize) {
644
+ const lowPriorityIndex = this.pendingQueue.findIndex((p) => p.narration.priority === "low");
645
+ if (lowPriorityIndex >= 0) {
646
+ this.pendingQueue.splice(lowPriorityIndex, 1);
647
+ }
648
+ else {
649
+ // No low priority items, remove oldest
650
+ this.pendingQueue.shift();
651
+ }
652
+ }
653
+ return null;
654
+ }
655
+ /**
656
+ * Pop the highest priority pending narration
657
+ */
658
+ popHighestPriority() {
659
+ if (this.pendingQueue.length === 0) {
660
+ return null;
661
+ }
662
+ // Priority order: high > normal > low
663
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
664
+ // Find highest priority
665
+ let bestIndex = 0;
666
+ let bestPriority = priorityOrder[this.pendingQueue[0].narration.priority];
667
+ for (let i = 1; i < this.pendingQueue.length; i++) {
668
+ const priority = priorityOrder[this.pendingQueue[i].narration.priority];
669
+ if (priority < bestPriority) {
670
+ bestIndex = i;
671
+ bestPriority = priority;
672
+ }
673
+ }
674
+ // Remove and return
675
+ const [item] = this.pendingQueue.splice(bestIndex, 1);
676
+ return item.narration;
677
+ }
678
+ }
679
+ /**
680
+ * Get narration configuration from voice settings config.
681
+ *
682
+ * @param voiceConfig - Optional narration settings from project config.json
683
+ */
684
+ export function getNarrationConfig(voiceConfig) {
685
+ const config = {};
686
+ if (voiceConfig?.narration?.narrateToolUse !== undefined) {
687
+ config.narrateToolUse = voiceConfig.narration.narrateToolUse;
688
+ }
689
+ if (voiceConfig?.narration?.narrateToolResults !== undefined) {
690
+ config.narrateToolResults = voiceConfig.narration.narrateToolResults;
691
+ }
692
+ if (voiceConfig?.narration?.narrateAssistantMessages !== undefined) {
693
+ config.narrateAssistantMessages = voiceConfig.narration.narrateAssistantMessages;
694
+ }
695
+ return config;
696
+ }
697
+ /**
698
+ * Global narration service instance
699
+ * Lazy-initialized on first use
700
+ */
701
+ let narrationServiceInstance = null;
702
+ /**
703
+ * Get or create the global narration service instance
704
+ *
705
+ * Configuration priority (highest to lowest):
706
+ * 1. config parameter (explicit override)
707
+ * 2. voiceConfig (from project config.json)
708
+ * 3. Environment variables (backwards compatibility)
709
+ * 4. Default values
710
+ *
711
+ * @param config - Optional configuration override (takes precedence over everything)
712
+ * @param voiceConfig - Optional voice settings from project config.json
713
+ * @returns The narration service instance
714
+ */
715
+ export function getNarrationService(config, voiceConfig) {
716
+ if (!narrationServiceInstance) {
717
+ // Merge: voiceConfig/env < provided config
718
+ const baseConfig = getNarrationConfig(voiceConfig);
719
+ narrationServiceInstance = new NarrationService({ ...baseConfig, ...config });
720
+ }
721
+ return narrationServiceInstance;
722
+ }
723
+ /**
724
+ * Reset the global narration service instance (for testing)
725
+ */
726
+ export function resetNarrationService() {
727
+ narrationServiceInstance = null;
728
+ }
729
+ //# sourceMappingURL=narration-service.js.map