@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.
- package/dist/better-sqlite3-loader.d.ts +9 -0
- package/dist/better-sqlite3-loader.d.ts.map +1 -0
- package/dist/better-sqlite3-loader.js +24 -0
- package/dist/better-sqlite3-loader.js.map +1 -0
- package/dist/execution/executors/agent-executor-wrapper.d.ts +6 -0
- package/dist/execution/executors/agent-executor-wrapper.d.ts.map +1 -1
- package/dist/execution/executors/agent-executor-wrapper.js +75 -4
- package/dist/execution/executors/agent-executor-wrapper.js.map +1 -1
- package/dist/execution/executors/executor-factory.d.ts +9 -6
- package/dist/execution/executors/executor-factory.d.ts.map +1 -1
- package/dist/execution/executors/executor-factory.js +6 -5
- package/dist/execution/executors/executor-factory.js.map +1 -1
- package/dist/execution/worktree/config.js +1 -1
- package/dist/execution/worktree/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/public/assets/index-B1p5HV93.css +1 -0
- package/dist/public/assets/index-qqIsBBjJ.js +3836 -0
- package/dist/public/assets/index-qqIsBBjJ.js.map +1 -0
- package/dist/public/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/dist/public/index.html +2 -2
- package/dist/public/kokoro-test.html +197 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/config.js +39 -0
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/executions.d.ts.map +1 -1
- package/dist/routes/executions.js +9 -0
- package/dist/routes/executions.js.map +1 -1
- package/dist/routes/voice.d.ts +12 -0
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +387 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +2 -1
- package/dist/routes/workflows.js.map +1 -1
- package/dist/services/db.d.ts +1 -1
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +2 -2
- package/dist/services/db.js.map +1 -1
- package/dist/services/execution-service.d.ts +8 -0
- package/dist/services/execution-service.d.ts.map +1 -1
- package/dist/services/execution-service.js +27 -5
- package/dist/services/execution-service.js.map +1 -1
- package/dist/services/narration-service.d.ts +304 -0
- package/dist/services/narration-service.d.ts.map +1 -0
- package/dist/services/narration-service.js +729 -0
- package/dist/services/narration-service.js.map +1 -0
- package/dist/services/stt-providers/index.d.ts +21 -0
- package/dist/services/stt-providers/index.d.ts.map +1 -0
- package/dist/services/stt-providers/index.js +32 -0
- package/dist/services/stt-providers/index.js.map +1 -0
- package/dist/services/stt-providers/openai-whisper.d.ts +66 -0
- package/dist/services/stt-providers/openai-whisper.d.ts.map +1 -0
- package/dist/services/stt-providers/openai-whisper.js +137 -0
- package/dist/services/stt-providers/openai-whisper.js.map +1 -0
- package/dist/services/stt-providers/whisper-local.d.ts +64 -0
- package/dist/services/stt-providers/whisper-local.d.ts.map +1 -0
- package/dist/services/stt-providers/whisper-local.js +166 -0
- package/dist/services/stt-providers/whisper-local.js.map +1 -0
- package/dist/services/stt-service.d.ts +160 -0
- package/dist/services/stt-service.d.ts.map +1 -0
- package/dist/services/stt-service.js +246 -0
- package/dist/services/stt-service.js.map +1 -0
- package/dist/services/tts-providers/browser-tts.d.ts +64 -0
- package/dist/services/tts-providers/browser-tts.d.ts.map +1 -0
- package/dist/services/tts-providers/browser-tts.js +89 -0
- package/dist/services/tts-providers/browser-tts.js.map +1 -0
- package/dist/services/tts-providers/index.d.ts +20 -0
- package/dist/services/tts-providers/index.d.ts.map +1 -0
- package/dist/services/tts-providers/index.js +31 -0
- package/dist/services/tts-providers/index.js.map +1 -0
- package/dist/services/tts-service.d.ts +190 -0
- package/dist/services/tts-service.d.ts.map +1 -0
- package/dist/services/tts-service.js +296 -0
- package/dist/services/tts-service.js.map +1 -0
- package/dist/services/tts-sidecar-manager.d.ts +276 -0
- package/dist/services/tts-sidecar-manager.d.ts.map +1 -0
- package/dist/services/tts-sidecar-manager.js +665 -0
- package/dist/services/tts-sidecar-manager.js.map +1 -0
- package/dist/services/websocket.d.ts +31 -1
- package/dist/services/websocket.d.ts.map +1 -1
- package/dist/services/websocket.js +149 -0
- package/dist/services/websocket.js.map +1 -1
- package/dist/services/worktree-sync-service.d.ts +17 -2
- package/dist/services/worktree-sync-service.d.ts.map +1 -1
- package/dist/services/worktree-sync-service.js +103 -6
- package/dist/services/worktree-sync-service.js.map +1 -1
- package/dist/utils/voice-config.d.ts +26 -0
- package/dist/utils/voice-config.d.ts.map +1 -0
- package/dist/utils/voice-config.js +48 -0
- package/dist/utils/voice-config.js.map +1 -0
- package/dist/workers/execution-worker.js +12 -4
- package/dist/workers/execution-worker.js.map +1 -1
- package/dist/workflow/base-workflow-engine.d.ts +3 -1
- package/dist/workflow/base-workflow-engine.d.ts.map +1 -1
- package/dist/workflow/base-workflow-engine.js.map +1 -1
- package/dist/workflow/engines/orchestrator-engine.d.ts +5 -1
- package/dist/workflow/engines/orchestrator-engine.d.ts.map +1 -1
- package/dist/workflow/engines/orchestrator-engine.js +4 -1
- package/dist/workflow/engines/orchestrator-engine.js.map +1 -1
- package/dist/workflow/engines/sequential-engine.d.ts +9 -2
- package/dist/workflow/engines/sequential-engine.d.ts.map +1 -1
- package/dist/workflow/engines/sequential-engine.js +102 -22
- package/dist/workflow/engines/sequential-engine.js.map +1 -1
- package/dist/workflow/workflow-engine.d.ts +8 -1
- package/dist/workflow/workflow-engine.d.ts.map +1 -1
- package/package.json +13 -4
- package/dist/public/assets/index-D4AKx6EO.css +0 -1
- package/dist/public/assets/index-DorQqwGV.js +0 -927
- 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
|