skyloom 1.14.6 → 1.15.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 (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
@@ -1,500 +1,500 @@
1
- /**
2
- * Module-level helpers for core/agent — parsing, signatures, similarity, labels.
3
- *
4
- * Pure functions / constants only: no state, no agent reference, safe to import anywhere.
5
- */
6
-
7
- import crypto from 'crypto';
8
- import type { Message } from './memory';
9
- import type { ToolRegistry } from './tool';
10
-
11
- // ── Tool labels ──
12
-
13
- const TOOL_LABELS: Record<string, string> = {
14
- read_file: 'Reading {path}',
15
- write_file: 'Writing {path}',
16
- edit_file: 'Editing {path}',
17
- list_directory: 'Listing {path}',
18
- file_search: 'Searching {directory}/{pattern}',
19
- code_search: "Searching for '{query}'",
20
- grep: "Grepping '{pattern}'",
21
- shell_exec: 'Running: {command}',
22
- http_get: 'GET {url}',
23
- http_post: 'POST {url}',
24
- web_search: 'Searching: {query}',
25
- move_file: 'Moving {src}',
26
- copy_file: 'Copying {src}',
27
- delete_file: 'Deleting {path}',
28
- get_cwd: 'Getting working directory',
29
- tree: 'Tree {directory}',
30
- lint_file: 'Linting {path}',
31
- scan_deps: 'Scanning {directory}',
32
- fetch_page: 'Fetching {url}',
33
- delegate_to: 'Delegating to {agent}: {task}',
34
- use_skill: 'Activating {name}',
35
- list_skills: 'Listing available skills',
36
- git_status: 'Git status',
37
- git_diff: 'Git diff',
38
- git_log: 'Git log',
39
- git_add: 'Git add {files}',
40
- git_commit: 'Git commit',
41
- git_checkout: 'Git checkout {branch}',
42
- launch_app: 'Launching {name}',
43
- open_path: 'Opening {target}',
44
- browser_open: 'Opening {url} in browser',
45
- list_installed_apps: 'Listing installed apps',
46
- system_info: 'System info',
47
- system_diagnose: 'System diagnosis',
48
- list_processes: 'Listing processes',
49
- kill_process: 'Killing {target}',
50
- package_manager: 'Package {action} {name}',
51
- service_control: 'Service {action} {name}',
52
- mcp_list_servers: 'Listing MCP servers',
53
- mcp_add_server: 'Adding MCP server {name}',
54
- mcp_remove_server: 'Removing MCP server {name}',
55
- mcp_scaffold_server: 'Scaffolding MCP server {name}',
56
- remember: 'Remembering: {note}',
57
- };
58
-
59
- // ── Regex patterns ──
60
-
61
- const RE_OBJ_OR_ARRAY = /(\{.*\}|\[.*\])/s;
62
- const RE_KV_DETECT = /\b\w[\w\d_]*\s*=/;
63
- const RE_KV_PAIRS = /(\w[\w\d_]*)\s*=\s*("[^"]*"|'[^']*'|[\w\d_.+-]+)/g;
64
- const RE_NONE_LITERAL = /:\s*None\s*([,}])/g;
65
- const RE_TRUE_LITERAL = /:\s*True\s*([,}])/g;
66
- const RE_FALSE_LITERAL = /:\s*False\s*([,}])/g;
67
- const RE_PY_NONE = /\bNone\b/g;
68
- const RE_PY_TRUE = /\bTrue\b/g;
69
- const RE_PY_FALSE = /\bFalse\b/g;
70
- const RE_UNQUOTED_KEY = /([{,]\s*)(\w[\w\d_]*)(\s*:)/g;
71
- const RE_TRAILING_COMMA = /,\s*([}\]])/g;
72
- const RE_UNQUOTED_STRING = /(:\s*)([a-zA-Z_.][a-zA-Z0-9_ ./\\@.\-+#~$]*?)(\s*[,}\]])/g;
73
-
74
- // ── Tool-signature loop detector tuning ──
75
- // These count *identical* tool-call signatures (same name + same args). Real
76
- // editing workflows produce distinct signatures (different file/old_text), so
77
- // a moderate window/threshold gives them room without disarming the loop guard:
78
- // an identical call repeated 10x is always a stuck loop, never legitimate work.
79
- export const SIG_WINDOW = 16; // observation window for repeated signatures
80
- export const SIG_LOOP_HINT = 6; // warn once after this many identical repeats
81
- export const SIG_LOOP_HARDSTOP = 10; // force-stop after this many identical repeats
82
-
83
- // ── Tool-failure markers ──
84
- const TOOL_FAILURE_MARKERS = [
85
- 'no results found',
86
- 'no matches for',
87
- 'file not found',
88
- 'directory not found',
89
- 'permission denied',
90
- 'status: 4',
91
- 'status: 5',
92
- 'request timed out',
93
- 'timed out',
94
- 'connection refused',
95
- 'name or service not known',
96
- 'ssl',
97
- '[error',
98
- 'error: tool',
99
- 'error: file',
100
- 'error: directory',
101
- 'circuitbreakeropen',
102
- 'execution failed:',
103
- ];
104
-
105
- // ── File-producing tools ──
106
- const FILE_PRODUCING_TOOLS: Record<string, string[]> = {
107
- write_file: ['path'],
108
- edit_file: ['path'],
109
- copy_file: ['dst', 'destination'],
110
- move_file: ['dst', 'destination'],
111
- };
112
-
113
- // ── Functions ──
114
-
115
- /**
116
- * Parse tool call JSON with multi-stage repair for LLM output quirks.
117
- */
118
- export function parseToolArgs(raw: string): Record<string, any> | null {
119
- if (!raw || !raw.trim()) return null;
120
-
121
- let cleaned = raw.trim();
122
-
123
- // 1. Direct parse
124
- try { return JSON.parse(cleaned); } catch { /* continue */ }
125
-
126
- // 2. Strip markdown code fences
127
- if (cleaned.startsWith('```')) {
128
- const nl = cleaned.indexOf('\n');
129
- if (nl >= 0) cleaned = cleaned.slice(nl + 1);
130
- const end = cleaned.lastIndexOf('```');
131
- if (end >= 0) cleaned = cleaned.slice(0, end);
132
- cleaned = cleaned.trim();
133
- try { return JSON.parse(cleaned); } catch { /* continue */ }
134
- }
135
-
136
- // 3. Extract first JSON object/array from surrounding text
137
- const objMatch = RE_OBJ_OR_ARRAY.exec(cleaned);
138
- if (objMatch) {
139
- cleaned = objMatch[1];
140
- try { return JSON.parse(cleaned); } catch { /* continue */ }
141
- }
142
-
143
- // 4. Key=value format: query="weather", count=5 -> {"query": "weather", "count": 5}
144
- if (!cleaned.startsWith('{') && RE_KV_DETECT.test(cleaned)) {
145
- const kvPairs: string[] = [];
146
- let m: RegExpExecArray | null;
147
- while ((m = RE_KV_PAIRS.exec(cleaned)) !== null) {
148
- const key = m[1];
149
- let val = m[2];
150
- if (val.startsWith("'") && val.endsWith("'")) {
151
- val = '"' + val.slice(1, -1) + '"';
152
- }
153
- kvPairs.push(`"${key}": ${val}`);
154
- }
155
- if (kvPairs.length > 0) {
156
- let jsonStr = '{' + kvPairs.join(', ') + '}';
157
- jsonStr = jsonStr.replace(RE_NONE_LITERAL, ': null$1');
158
- jsonStr = jsonStr.replace(RE_TRUE_LITERAL, ': true$1');
159
- jsonStr = jsonStr.replace(RE_FALSE_LITERAL, ': false$1');
160
- try { return JSON.parse(jsonStr); } catch { /* continue */ }
161
- }
162
- }
163
-
164
- // 5. Python -> JSON literals
165
- cleaned = cleaned.replace(RE_PY_NONE, 'null');
166
- cleaned = cleaned.replace(RE_PY_TRUE, 'true');
167
- cleaned = cleaned.replace(RE_PY_FALSE, 'false');
168
-
169
- // 6. Backtick -> double quote
170
- cleaned = cleaned.replace(/`/g, '"');
171
-
172
- // 7. Fix single-quote strings
173
- if (cleaned.includes("'")) {
174
- cleaned = cleaned.replace(/'/g, '"');
175
- }
176
-
177
- // 8. Fix unquoted keys
178
- cleaned = cleaned.replace(RE_UNQUOTED_KEY, '$1"$2"$3');
179
-
180
- // 9. Fix trailing commas
181
- cleaned = cleaned.replace(RE_TRAILING_COMMA, '$1');
182
- cleaned = cleaned.replace(/,\s*$/, '').trim();
183
-
184
- // 10. Fix unquoted string values
185
- cleaned = cleaned.replace(RE_UNQUOTED_STRING, (match, prefix: string, word: string, suffix: string) => {
186
- if (word === 'null' || word === 'true' || word === 'false') return match;
187
- if (/^-?\d+(\.\d+)?$/.test(word)) return match;
188
- if (word.startsWith('"') || word.startsWith('{') || word.startsWith('[')) return match;
189
- return `${prefix}"${word}"${suffix}`;
190
- });
191
-
192
- // 11. Attempt parse
193
- try { return JSON.parse(cleaned); } catch { /* continue */ }
194
-
195
- // 12. Balanced-brace extraction
196
- let depth = 0;
197
- let start = -1;
198
- for (let i = 0; i < cleaned.length; i++) {
199
- const ch = cleaned[i];
200
- if (ch === '{') {
201
- if (depth === 0) start = i;
202
- depth++;
203
- } else if (ch === '}') {
204
- depth--;
205
- if (depth === 0 && start >= 0) {
206
- try { return JSON.parse(cleaned.slice(start, i + 1)); } catch { /* continue */ }
207
- }
208
- }
209
- }
210
-
211
- // Auto-close unclosed braces
212
- if (start >= 0 && depth > 0) {
213
- const candidate = cleaned.slice(start) + '}'.repeat(depth);
214
- try { return JSON.parse(candidate); } catch { /* ignore */ }
215
- }
216
-
217
- return null;
218
- }
219
-
220
- /**
221
- * Heuristic: does this tool result indicate a dead-end the LLM should stop retrying?
222
- */
223
- export function looksLikeFailedToolResult(result: string): boolean {
224
- if (!result) return true;
225
- const head = result.slice(0, 300).toLowerCase();
226
- return TOOL_FAILURE_MARKERS.some(m => head.includes(m));
227
- }
228
-
229
- /**
230
- * Walk assistant turns and pull out file paths that write_file / edit_file / etc. touched.
231
- */
232
- export function extractFilePathsFromMessages(messages: Message[]): string[] {
233
- const toolResults: Record<string, string> = {};
234
- for (const m of messages) {
235
- if (m.role === 'tool' && m.toolCallId) {
236
- toolResults[m.toolCallId] = m.content || '';
237
- }
238
- }
239
-
240
- const paths: string[] = [];
241
- const seen = new Set<string>();
242
-
243
- for (const m of messages) {
244
- if (m.role !== 'assistant' || !m.toolCalls) continue;
245
- for (const tc of m.toolCalls) {
246
- const name = tc.function?.name || '';
247
- const argKeys = FILE_PRODUCING_TOOLS[name];
248
- if (!argKeys) continue;
249
-
250
- const raw = tc.function?.arguments || '';
251
- let args: Record<string, any>;
252
- try {
253
- args = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
254
- } catch {
255
- continue;
256
- }
257
- if (typeof args !== 'object') continue;
258
-
259
- let filePath: string | undefined;
260
- for (const k of argKeys) {
261
- const v = args[k];
262
- if (typeof v === 'string' && v.trim()) {
263
- filePath = v.trim();
264
- break;
265
- }
266
- }
267
- if (!filePath) continue;
268
-
269
- const result = toolResults[tc.id || ''] || '';
270
- if (result && (result.startsWith('Error:') || result.startsWith('[Error'))) continue;
271
- if (!seen.has(filePath)) {
272
- seen.add(filePath);
273
- paths.push(filePath);
274
- }
275
- }
276
- }
277
- return paths;
278
- }
279
-
280
- /**
281
- * Append a deterministic artifact footer when the agent wrote files during this task.
282
- */
283
- export function enrichResponseWithArtifacts(content: string, filePaths: string[]): string {
284
- if (!filePaths.length) return content;
285
- const body = content || '';
286
- const missing = filePaths.filter(p => !body.includes(p));
287
- if (!missing.length) return body;
288
-
289
- const lines = ['', '> **Artifacts produced**'];
290
- for (const p of filePaths) {
291
- const marker = body.includes(p) ? ' — already cited above' : '';
292
- lines.push(`> - \`${p}\`${marker}`);
293
- }
294
- return body.trimEnd() + '\n\n' + lines.join('\n');
295
- }
296
-
297
- /**
298
- * Compact, stable fingerprint of a tool call for loop detection.
299
- */
300
- export function toolCallSignature(toolName: string, args: Record<string, any> | null): string {
301
- const a = args || {};
302
-
303
- if (toolName === 'edit_file') {
304
- const osVal = (a.old_text as string) || (a.oldText as string) || '';
305
- if (osVal) {
306
- const hash = crypto.createHash('sha1').update(osVal).digest('hex').slice(0, 8);
307
- return `edit_file:${a.path || ''}#${hash}`;
308
- }
309
- return `edit_file:${a.path || ''}`;
310
- }
311
- if (['write_file', 'read_file', 'delete_file'].includes(toolName)) {
312
- return `${toolName}:${a.path || ''}`;
313
- }
314
- if (['copy_file', 'move_file'].includes(toolName)) {
315
- return `${toolName}:${a.src || a.source || ''}->${a.dst || a.destination || ''}`;
316
- }
317
- if (['run_bash', 'bash', 'shell', 'run_shell'].includes(toolName)) {
318
- const cmd = (a.command || a.cmd || '') as string;
319
- if (!cmd) return toolName;
320
- const first = cmd.split(/\s+/)[0] || '';
321
- return `${toolName}:${first.slice(0, 30)}`;
322
- }
323
- if (['web_search', 'search', 'search_web', 'search_files', 'grep'].includes(toolName)) {
324
- let q = ((a.query || a.pattern || '') as string).slice(0, 40);
325
- q = q.replace(/[\s"'「」『』""''‘’“”]/g, '');
326
- return `${toolName}:${q.toLowerCase().slice(0, 30)}`;
327
- }
328
- if (['fetch_page', 'fetch_web_page', 'http_get', 'http_post'].includes(toolName)) {
329
- return `${toolName}:${(a.url || '').slice(0, 60)}`;
330
- }
331
- if (toolName === 'delegate_to') {
332
- return `delegate_to:${a.agent || ''}`;
333
- }
334
-
335
- // Generic fallback
336
- if (Object.keys(a).length > 0) {
337
- const blob = JSON.stringify(a, Object.keys(a).sort());
338
- const hash = crypto.createHash('sha1').update(blob.slice(0, 300)).digest('hex').slice(0, 8);
339
- return `${toolName}:${hash}`;
340
- }
341
- return toolName;
342
- }
343
-
344
- /**
345
- * Cheap similarity for narration-loop detection.
346
- */
347
- export function textSimilarity(a: string, b: string): number {
348
- if (a.length < 12 || b.length < 12) return 0.0;
349
- const longer = a.length >= b.length ? a : b;
350
- const shorter = a.length < b.length ? a : b;
351
-
352
- if (longer.length === 0) return 0.0;
353
-
354
- // Use simple character overlap as a cheap similarity measure
355
- const common = [...shorter].filter(ch => longer.includes(ch)).length;
356
- return common / longer.length;
357
- }
358
-
359
- /**
360
- * Produce a tool-result error string that helps the LLM recover.
361
- */
362
- export function formatArgsParseError(toolName: string, rawArgs: string): string {
363
- const stripped = rawArgs.trimEnd();
364
- const hasClosingBrace = stripped.endsWith('}') || stripped.endsWith(']');
365
-
366
- // Crude quote counter
367
- let inString = false;
368
- let i = 0;
369
- while (i < stripped.length) {
370
- const ch = stripped[i];
371
- if (ch === '\\' && inString) { i += 2; continue; }
372
- if (ch === '"') inString = !inString;
373
- i++;
374
- }
375
- const looksTruncated = inString || !hasClosingBrace;
376
- const preview = rawArgs.slice(0, 200) + (rawArgs.length > 200 ? '...[truncated]' : '');
377
-
378
- if (looksTruncated) {
379
- return (
380
- `Error: tool '${toolName}' arguments were truncated by the model's ` +
381
- `output budget (max_tokens). The JSON ended mid-value so it cannot ` +
382
- `be parsed. For large content, split into multiple smaller calls. ` +
383
- `Args preview: ${preview}`
384
- );
385
- }
386
- return `Error: invalid JSON in tool call arguments for '${toolName}': ${preview}`;
387
- }
388
-
389
- /**
390
- * Return the closest existing tool names for a hallucinated name.
391
- */
392
- export function suggestToolNames(missing: string, registry: ToolRegistry, maxN: number = 3): string[] {
393
- const allNames = registry.listNames();
394
- if (!allNames.length) return [];
395
-
396
- const missingLower = missing.toLowerCase();
397
- const missingChunks = missingLower.split('_').filter(c => c.length >= 3);
398
-
399
- // Score by name overlap
400
- const scored: Array<{ name: string; score: number }> = [];
401
- const descScored: Array<{ name: string; score: number }> = [];
402
-
403
- for (const n of allNames) {
404
- const nlow = n.toLowerCase();
405
- let nameScore = 0;
406
- for (const chunk of missingChunks) {
407
- if (nlow.includes(chunk)) nameScore += 2;
408
- }
409
- for (const chunk of nlow.split('_')) {
410
- if (chunk.length >= 3 && missingLower.includes(chunk)) nameScore += 1;
411
- }
412
- if (nameScore > 0) {
413
- scored.push({ name: n, score: nameScore });
414
- }
415
-
416
- const tool = registry.get(n);
417
- if (!tool) continue;
418
- const desc = tool.description.toLowerCase();
419
- const descScore = missingChunks.filter(ch => desc.includes(ch)).length;
420
- if (descScore > 0) {
421
- descScored.push({ name: n, score: descScore });
422
- }
423
- }
424
-
425
- if (scored.length > 0) {
426
- scored.sort((a, b) => b.score - a.score);
427
- return scored.slice(0, maxN).map(s => s.name);
428
- }
429
- descScored.sort((a, b) => b.score - a.score);
430
- return descScored.slice(0, maxN).map(s => s.name);
431
- }
432
-
433
- /**
434
- * Build a human-readable one-liner for a tool call.
435
- */
436
- export function toolStatusLabel(name: string, args: Record<string, any>): string {
437
- const template = TOOL_LABELS[name];
438
- let label: string;
439
- if (template) {
440
- try {
441
- label = template.replace(/\{(\w+)\}/g, (_m, key) => String(args[key] ?? ''));
442
- } catch {
443
- label = `${name}...`;
444
- }
445
- } else {
446
- label = `${name}...`;
447
- }
448
- if (label.length > 100) {
449
- label = label.slice(0, 97) + '...';
450
- }
451
- return label;
452
- }
453
-
454
- /**
455
- * Parse LLM fact-extraction output into a list of {key,value,category} records.
456
- * Tolerant of raw JSON, markdown-fenced JSON, and a JSON array embedded in prose.
457
- * Pure — extracted from BaseAgent (Phase 3).
458
- */
459
- export function parseExtractedFacts(content: string): Array<{ key: string; value: any; category?: string }> {
460
- const text = (content || '').trim();
461
- if (!text) return [];
462
-
463
- // 1. Direct parse
464
- try {
465
- const data = JSON.parse(text);
466
- if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
467
- } catch { /* continue */ }
468
-
469
- // 2. Markdown-fenced JSON
470
- const fenceMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
471
- if (fenceMatch) {
472
- try {
473
- const data = JSON.parse(fenceMatch[1]);
474
- if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
475
- } catch { /* continue */ }
476
- }
477
-
478
- // 3. First JSON array substring
479
- const arrayMatch = text.match(/\[[\s\S]*?\]/);
480
- if (arrayMatch) {
481
- try {
482
- const data = JSON.parse(arrayMatch[0]);
483
- if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
484
- } catch { /* continue */ }
485
- }
486
- return [];
487
- }
488
-
489
- /**
490
- * Build a short fallback line shown when a turn ends with delegate_to calls but no plain text.
491
- */
492
- export function synthesizeDelegationSummary(delegations: Array<[string, boolean]>): string {
493
- if (!delegations.length) return '';
494
- const ok = delegations.filter(([_, s]) => s).map(([n]) => n);
495
- const failed = delegations.filter(([_, s]) => !s).map(([n]) => n);
496
- const parts: string[] = [];
497
- if (ok.length) parts.push('Delegated: ' + ok.join(', '));
498
- if (failed.length) parts.push('Failed: ' + failed.join(', '));
499
- return '[' + parts.join(' | ') + ']';
500
- }
1
+ /**
2
+ * Module-level helpers for core/agent — parsing, signatures, similarity, labels.
3
+ *
4
+ * Pure functions / constants only: no state, no agent reference, safe to import anywhere.
5
+ */
6
+
7
+ import crypto from 'crypto';
8
+ import type { Message } from './memory';
9
+ import type { ToolRegistry } from './tool';
10
+
11
+ // ── Tool labels ──
12
+
13
+ const TOOL_LABELS: Record<string, string> = {
14
+ read_file: 'Reading {path}',
15
+ write_file: 'Writing {path}',
16
+ edit_file: 'Editing {path}',
17
+ list_directory: 'Listing {path}',
18
+ file_search: 'Searching {directory}/{pattern}',
19
+ code_search: "Searching for '{query}'",
20
+ grep: "Grepping '{pattern}'",
21
+ shell_exec: 'Running: {command}',
22
+ http_get: 'GET {url}',
23
+ http_post: 'POST {url}',
24
+ web_search: 'Searching: {query}',
25
+ move_file: 'Moving {src}',
26
+ copy_file: 'Copying {src}',
27
+ delete_file: 'Deleting {path}',
28
+ get_cwd: 'Getting working directory',
29
+ tree: 'Tree {directory}',
30
+ lint_file: 'Linting {path}',
31
+ scan_deps: 'Scanning {directory}',
32
+ fetch_page: 'Fetching {url}',
33
+ delegate_to: 'Delegating to {agent}: {task}',
34
+ use_skill: 'Activating {name}',
35
+ list_skills: 'Listing available skills',
36
+ git_status: 'Git status',
37
+ git_diff: 'Git diff',
38
+ git_log: 'Git log',
39
+ git_add: 'Git add {files}',
40
+ git_commit: 'Git commit',
41
+ git_checkout: 'Git checkout {branch}',
42
+ launch_app: 'Launching {name}',
43
+ open_path: 'Opening {target}',
44
+ browser_open: 'Opening {url} in browser',
45
+ list_installed_apps: 'Listing installed apps',
46
+ system_info: 'System info',
47
+ system_diagnose: 'System diagnosis',
48
+ list_processes: 'Listing processes',
49
+ kill_process: 'Killing {target}',
50
+ package_manager: 'Package {action} {name}',
51
+ service_control: 'Service {action} {name}',
52
+ mcp_list_servers: 'Listing MCP servers',
53
+ mcp_add_server: 'Adding MCP server {name}',
54
+ mcp_remove_server: 'Removing MCP server {name}',
55
+ mcp_scaffold_server: 'Scaffolding MCP server {name}',
56
+ remember: 'Remembering: {note}',
57
+ };
58
+
59
+ // ── Regex patterns ──
60
+
61
+ const RE_OBJ_OR_ARRAY = /(\{.*\}|\[.*\])/s;
62
+ const RE_KV_DETECT = /\b\w[\w\d_]*\s*=/;
63
+ const RE_KV_PAIRS = /(\w[\w\d_]*)\s*=\s*("[^"]*"|'[^']*'|[\w\d_.+-]+)/g;
64
+ const RE_NONE_LITERAL = /:\s*None\s*([,}])/g;
65
+ const RE_TRUE_LITERAL = /:\s*True\s*([,}])/g;
66
+ const RE_FALSE_LITERAL = /:\s*False\s*([,}])/g;
67
+ const RE_PY_NONE = /\bNone\b/g;
68
+ const RE_PY_TRUE = /\bTrue\b/g;
69
+ const RE_PY_FALSE = /\bFalse\b/g;
70
+ const RE_UNQUOTED_KEY = /([{,]\s*)(\w[\w\d_]*)(\s*:)/g;
71
+ const RE_TRAILING_COMMA = /,\s*([}\]])/g;
72
+ const RE_UNQUOTED_STRING = /(:\s*)([a-zA-Z_.][a-zA-Z0-9_ ./\\@.\-+#~$]*?)(\s*[,}\]])/g;
73
+
74
+ // ── Tool-signature loop detector tuning ──
75
+ // These count *identical* tool-call signatures (same name + same args). Real
76
+ // editing workflows produce distinct signatures (different file/old_text), so
77
+ // a moderate window/threshold gives them room without disarming the loop guard:
78
+ // an identical call repeated 10x is always a stuck loop, never legitimate work.
79
+ export const SIG_WINDOW = 16; // observation window for repeated signatures
80
+ export const SIG_LOOP_HINT = 6; // warn once after this many identical repeats
81
+ export const SIG_LOOP_HARDSTOP = 10; // force-stop after this many identical repeats
82
+
83
+ // ── Tool-failure markers ──
84
+ const TOOL_FAILURE_MARKERS = [
85
+ 'no results found',
86
+ 'no matches for',
87
+ 'file not found',
88
+ 'directory not found',
89
+ 'permission denied',
90
+ 'status: 4',
91
+ 'status: 5',
92
+ 'request timed out',
93
+ 'timed out',
94
+ 'connection refused',
95
+ 'name or service not known',
96
+ 'ssl',
97
+ '[error',
98
+ 'error: tool',
99
+ 'error: file',
100
+ 'error: directory',
101
+ 'circuitbreakeropen',
102
+ 'execution failed:',
103
+ ];
104
+
105
+ // ── File-producing tools ──
106
+ const FILE_PRODUCING_TOOLS: Record<string, string[]> = {
107
+ write_file: ['path'],
108
+ edit_file: ['path'],
109
+ copy_file: ['dst', 'destination'],
110
+ move_file: ['dst', 'destination'],
111
+ };
112
+
113
+ // ── Functions ──
114
+
115
+ /**
116
+ * Parse tool call JSON with multi-stage repair for LLM output quirks.
117
+ */
118
+ export function parseToolArgs(raw: string): Record<string, any> | null {
119
+ if (!raw || !raw.trim()) return null;
120
+
121
+ let cleaned = raw.trim();
122
+
123
+ // 1. Direct parse
124
+ try { return JSON.parse(cleaned); } catch { /* continue */ }
125
+
126
+ // 2. Strip markdown code fences
127
+ if (cleaned.startsWith('```')) {
128
+ const nl = cleaned.indexOf('\n');
129
+ if (nl >= 0) cleaned = cleaned.slice(nl + 1);
130
+ const end = cleaned.lastIndexOf('```');
131
+ if (end >= 0) cleaned = cleaned.slice(0, end);
132
+ cleaned = cleaned.trim();
133
+ try { return JSON.parse(cleaned); } catch { /* continue */ }
134
+ }
135
+
136
+ // 3. Extract first JSON object/array from surrounding text
137
+ const objMatch = RE_OBJ_OR_ARRAY.exec(cleaned);
138
+ if (objMatch) {
139
+ cleaned = objMatch[1];
140
+ try { return JSON.parse(cleaned); } catch { /* continue */ }
141
+ }
142
+
143
+ // 4. Key=value format: query="weather", count=5 -> {"query": "weather", "count": 5}
144
+ if (!cleaned.startsWith('{') && RE_KV_DETECT.test(cleaned)) {
145
+ const kvPairs: string[] = [];
146
+ let m: RegExpExecArray | null;
147
+ while ((m = RE_KV_PAIRS.exec(cleaned)) !== null) {
148
+ const key = m[1];
149
+ let val = m[2];
150
+ if (val.startsWith("'") && val.endsWith("'")) {
151
+ val = '"' + val.slice(1, -1) + '"';
152
+ }
153
+ kvPairs.push(`"${key}": ${val}`);
154
+ }
155
+ if (kvPairs.length > 0) {
156
+ let jsonStr = '{' + kvPairs.join(', ') + '}';
157
+ jsonStr = jsonStr.replace(RE_NONE_LITERAL, ': null$1');
158
+ jsonStr = jsonStr.replace(RE_TRUE_LITERAL, ': true$1');
159
+ jsonStr = jsonStr.replace(RE_FALSE_LITERAL, ': false$1');
160
+ try { return JSON.parse(jsonStr); } catch { /* continue */ }
161
+ }
162
+ }
163
+
164
+ // 5. Python -> JSON literals
165
+ cleaned = cleaned.replace(RE_PY_NONE, 'null');
166
+ cleaned = cleaned.replace(RE_PY_TRUE, 'true');
167
+ cleaned = cleaned.replace(RE_PY_FALSE, 'false');
168
+
169
+ // 6. Backtick -> double quote
170
+ cleaned = cleaned.replace(/`/g, '"');
171
+
172
+ // 7. Fix single-quote strings
173
+ if (cleaned.includes("'")) {
174
+ cleaned = cleaned.replace(/'/g, '"');
175
+ }
176
+
177
+ // 8. Fix unquoted keys
178
+ cleaned = cleaned.replace(RE_UNQUOTED_KEY, '$1"$2"$3');
179
+
180
+ // 9. Fix trailing commas
181
+ cleaned = cleaned.replace(RE_TRAILING_COMMA, '$1');
182
+ cleaned = cleaned.replace(/,\s*$/, '').trim();
183
+
184
+ // 10. Fix unquoted string values
185
+ cleaned = cleaned.replace(RE_UNQUOTED_STRING, (match, prefix: string, word: string, suffix: string) => {
186
+ if (word === 'null' || word === 'true' || word === 'false') return match;
187
+ if (/^-?\d+(\.\d+)?$/.test(word)) return match;
188
+ if (word.startsWith('"') || word.startsWith('{') || word.startsWith('[')) return match;
189
+ return `${prefix}"${word}"${suffix}`;
190
+ });
191
+
192
+ // 11. Attempt parse
193
+ try { return JSON.parse(cleaned); } catch { /* continue */ }
194
+
195
+ // 12. Balanced-brace extraction
196
+ let depth = 0;
197
+ let start = -1;
198
+ for (let i = 0; i < cleaned.length; i++) {
199
+ const ch = cleaned[i];
200
+ if (ch === '{') {
201
+ if (depth === 0) start = i;
202
+ depth++;
203
+ } else if (ch === '}') {
204
+ depth--;
205
+ if (depth === 0 && start >= 0) {
206
+ try { return JSON.parse(cleaned.slice(start, i + 1)); } catch { /* continue */ }
207
+ }
208
+ }
209
+ }
210
+
211
+ // Auto-close unclosed braces
212
+ if (start >= 0 && depth > 0) {
213
+ const candidate = cleaned.slice(start) + '}'.repeat(depth);
214
+ try { return JSON.parse(candidate); } catch { /* ignore */ }
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Heuristic: does this tool result indicate a dead-end the LLM should stop retrying?
222
+ */
223
+ export function looksLikeFailedToolResult(result: string): boolean {
224
+ if (!result) return true;
225
+ const head = result.slice(0, 300).toLowerCase();
226
+ return TOOL_FAILURE_MARKERS.some(m => head.includes(m));
227
+ }
228
+
229
+ /**
230
+ * Walk assistant turns and pull out file paths that write_file / edit_file / etc. touched.
231
+ */
232
+ export function extractFilePathsFromMessages(messages: Message[]): string[] {
233
+ const toolResults: Record<string, string> = {};
234
+ for (const m of messages) {
235
+ if (m.role === 'tool' && m.toolCallId) {
236
+ toolResults[m.toolCallId] = m.content || '';
237
+ }
238
+ }
239
+
240
+ const paths: string[] = [];
241
+ const seen = new Set<string>();
242
+
243
+ for (const m of messages) {
244
+ if (m.role !== 'assistant' || !m.toolCalls) continue;
245
+ for (const tc of m.toolCalls) {
246
+ const name = tc.function?.name || '';
247
+ const argKeys = FILE_PRODUCING_TOOLS[name];
248
+ if (!argKeys) continue;
249
+
250
+ const raw = tc.function?.arguments || '';
251
+ let args: Record<string, any>;
252
+ try {
253
+ args = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
254
+ } catch {
255
+ continue;
256
+ }
257
+ if (typeof args !== 'object') continue;
258
+
259
+ let filePath: string | undefined;
260
+ for (const k of argKeys) {
261
+ const v = args[k];
262
+ if (typeof v === 'string' && v.trim()) {
263
+ filePath = v.trim();
264
+ break;
265
+ }
266
+ }
267
+ if (!filePath) continue;
268
+
269
+ const result = toolResults[tc.id || ''] || '';
270
+ if (result && (result.startsWith('Error:') || result.startsWith('[Error'))) continue;
271
+ if (!seen.has(filePath)) {
272
+ seen.add(filePath);
273
+ paths.push(filePath);
274
+ }
275
+ }
276
+ }
277
+ return paths;
278
+ }
279
+
280
+ /**
281
+ * Append a deterministic artifact footer when the agent wrote files during this task.
282
+ */
283
+ export function enrichResponseWithArtifacts(content: string, filePaths: string[]): string {
284
+ if (!filePaths.length) return content;
285
+ const body = content || '';
286
+ const missing = filePaths.filter(p => !body.includes(p));
287
+ if (!missing.length) return body;
288
+
289
+ const lines = ['', '> **Artifacts produced**'];
290
+ for (const p of filePaths) {
291
+ const marker = body.includes(p) ? ' — already cited above' : '';
292
+ lines.push(`> - \`${p}\`${marker}`);
293
+ }
294
+ return body.trimEnd() + '\n\n' + lines.join('\n');
295
+ }
296
+
297
+ /**
298
+ * Compact, stable fingerprint of a tool call for loop detection.
299
+ */
300
+ export function toolCallSignature(toolName: string, args: Record<string, any> | null): string {
301
+ const a = args || {};
302
+
303
+ if (toolName === 'edit_file') {
304
+ const osVal = (a.old_text as string) || (a.oldText as string) || '';
305
+ if (osVal) {
306
+ const hash = crypto.createHash('sha1').update(osVal).digest('hex').slice(0, 8);
307
+ return `edit_file:${a.path || ''}#${hash}`;
308
+ }
309
+ return `edit_file:${a.path || ''}`;
310
+ }
311
+ if (['write_file', 'read_file', 'delete_file'].includes(toolName)) {
312
+ return `${toolName}:${a.path || ''}`;
313
+ }
314
+ if (['copy_file', 'move_file'].includes(toolName)) {
315
+ return `${toolName}:${a.src || a.source || ''}->${a.dst || a.destination || ''}`;
316
+ }
317
+ if (['run_bash', 'bash', 'shell', 'run_shell'].includes(toolName)) {
318
+ const cmd = (a.command || a.cmd || '') as string;
319
+ if (!cmd) return toolName;
320
+ const first = cmd.split(/\s+/)[0] || '';
321
+ return `${toolName}:${first.slice(0, 30)}`;
322
+ }
323
+ if (['web_search', 'search', 'search_web', 'search_files', 'grep'].includes(toolName)) {
324
+ let q = ((a.query || a.pattern || '') as string).slice(0, 40);
325
+ q = q.replace(/[\s"'「」『』""''‘’“”]/g, '');
326
+ return `${toolName}:${q.toLowerCase().slice(0, 30)}`;
327
+ }
328
+ if (['fetch_page', 'fetch_web_page', 'http_get', 'http_post'].includes(toolName)) {
329
+ return `${toolName}:${(a.url || '').slice(0, 60)}`;
330
+ }
331
+ if (toolName === 'delegate_to') {
332
+ return `delegate_to:${a.agent || ''}`;
333
+ }
334
+
335
+ // Generic fallback
336
+ if (Object.keys(a).length > 0) {
337
+ const blob = JSON.stringify(a, Object.keys(a).sort());
338
+ const hash = crypto.createHash('sha1').update(blob.slice(0, 300)).digest('hex').slice(0, 8);
339
+ return `${toolName}:${hash}`;
340
+ }
341
+ return toolName;
342
+ }
343
+
344
+ /**
345
+ * Cheap similarity for narration-loop detection.
346
+ */
347
+ export function textSimilarity(a: string, b: string): number {
348
+ if (a.length < 12 || b.length < 12) return 0.0;
349
+ const longer = a.length >= b.length ? a : b;
350
+ const shorter = a.length < b.length ? a : b;
351
+
352
+ if (longer.length === 0) return 0.0;
353
+
354
+ // Use simple character overlap as a cheap similarity measure
355
+ const common = [...shorter].filter(ch => longer.includes(ch)).length;
356
+ return common / longer.length;
357
+ }
358
+
359
+ /**
360
+ * Produce a tool-result error string that helps the LLM recover.
361
+ */
362
+ export function formatArgsParseError(toolName: string, rawArgs: string): string {
363
+ const stripped = rawArgs.trimEnd();
364
+ const hasClosingBrace = stripped.endsWith('}') || stripped.endsWith(']');
365
+
366
+ // Crude quote counter
367
+ let inString = false;
368
+ let i = 0;
369
+ while (i < stripped.length) {
370
+ const ch = stripped[i];
371
+ if (ch === '\\' && inString) { i += 2; continue; }
372
+ if (ch === '"') inString = !inString;
373
+ i++;
374
+ }
375
+ const looksTruncated = inString || !hasClosingBrace;
376
+ const preview = rawArgs.slice(0, 200) + (rawArgs.length > 200 ? '...[truncated]' : '');
377
+
378
+ if (looksTruncated) {
379
+ return (
380
+ `Error: tool '${toolName}' arguments were truncated by the model's ` +
381
+ `output budget (max_tokens). The JSON ended mid-value so it cannot ` +
382
+ `be parsed. For large content, split into multiple smaller calls. ` +
383
+ `Args preview: ${preview}`
384
+ );
385
+ }
386
+ return `Error: invalid JSON in tool call arguments for '${toolName}': ${preview}`;
387
+ }
388
+
389
+ /**
390
+ * Return the closest existing tool names for a hallucinated name.
391
+ */
392
+ export function suggestToolNames(missing: string, registry: ToolRegistry, maxN: number = 3): string[] {
393
+ const allNames = registry.listNames();
394
+ if (!allNames.length) return [];
395
+
396
+ const missingLower = missing.toLowerCase();
397
+ const missingChunks = missingLower.split('_').filter(c => c.length >= 3);
398
+
399
+ // Score by name overlap
400
+ const scored: Array<{ name: string; score: number }> = [];
401
+ const descScored: Array<{ name: string; score: number }> = [];
402
+
403
+ for (const n of allNames) {
404
+ const nlow = n.toLowerCase();
405
+ let nameScore = 0;
406
+ for (const chunk of missingChunks) {
407
+ if (nlow.includes(chunk)) nameScore += 2;
408
+ }
409
+ for (const chunk of nlow.split('_')) {
410
+ if (chunk.length >= 3 && missingLower.includes(chunk)) nameScore += 1;
411
+ }
412
+ if (nameScore > 0) {
413
+ scored.push({ name: n, score: nameScore });
414
+ }
415
+
416
+ const tool = registry.get(n);
417
+ if (!tool) continue;
418
+ const desc = tool.description.toLowerCase();
419
+ const descScore = missingChunks.filter(ch => desc.includes(ch)).length;
420
+ if (descScore > 0) {
421
+ descScored.push({ name: n, score: descScore });
422
+ }
423
+ }
424
+
425
+ if (scored.length > 0) {
426
+ scored.sort((a, b) => b.score - a.score);
427
+ return scored.slice(0, maxN).map(s => s.name);
428
+ }
429
+ descScored.sort((a, b) => b.score - a.score);
430
+ return descScored.slice(0, maxN).map(s => s.name);
431
+ }
432
+
433
+ /**
434
+ * Build a human-readable one-liner for a tool call.
435
+ */
436
+ export function toolStatusLabel(name: string, args: Record<string, any>): string {
437
+ const template = TOOL_LABELS[name];
438
+ let label: string;
439
+ if (template) {
440
+ try {
441
+ label = template.replace(/\{(\w+)\}/g, (_m, key) => String(args[key] ?? ''));
442
+ } catch {
443
+ label = `${name}...`;
444
+ }
445
+ } else {
446
+ label = `${name}...`;
447
+ }
448
+ if (label.length > 100) {
449
+ label = label.slice(0, 97) + '...';
450
+ }
451
+ return label;
452
+ }
453
+
454
+ /**
455
+ * Parse LLM fact-extraction output into a list of {key,value,category} records.
456
+ * Tolerant of raw JSON, markdown-fenced JSON, and a JSON array embedded in prose.
457
+ * Pure — extracted from BaseAgent (Phase 3).
458
+ */
459
+ export function parseExtractedFacts(content: string): Array<{ key: string; value: any; category?: string }> {
460
+ const text = (content || '').trim();
461
+ if (!text) return [];
462
+
463
+ // 1. Direct parse
464
+ try {
465
+ const data = JSON.parse(text);
466
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
467
+ } catch { /* continue */ }
468
+
469
+ // 2. Markdown-fenced JSON
470
+ const fenceMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
471
+ if (fenceMatch) {
472
+ try {
473
+ const data = JSON.parse(fenceMatch[1]);
474
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
475
+ } catch { /* continue */ }
476
+ }
477
+
478
+ // 3. First JSON array substring
479
+ const arrayMatch = text.match(/\[[\s\S]*?\]/);
480
+ if (arrayMatch) {
481
+ try {
482
+ const data = JSON.parse(arrayMatch[0]);
483
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
484
+ } catch { /* continue */ }
485
+ }
486
+ return [];
487
+ }
488
+
489
+ /**
490
+ * Build a short fallback line shown when a turn ends with delegate_to calls but no plain text.
491
+ */
492
+ export function synthesizeDelegationSummary(delegations: Array<[string, boolean]>): string {
493
+ if (!delegations.length) return '';
494
+ const ok = delegations.filter(([_, s]) => s).map(([n]) => n);
495
+ const failed = delegations.filter(([_, s]) => !s).map(([n]) => n);
496
+ const parts: string[] = [];
497
+ if (ok.length) parts.push('Delegated: ' + ok.join(', '));
498
+ if (failed.length) parts.push('Failed: ' + failed.join(', '));
499
+ return '[' + parts.join(' | ') + ']';
500
+ }