@possumtech/rummy 0.2.1

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 (120) hide show
  1. package/.env.example +55 -0
  2. package/LICENSE +21 -0
  3. package/PLUGINS.md +302 -0
  4. package/README.md +41 -0
  5. package/SPEC.md +524 -0
  6. package/lang/en.json +34 -0
  7. package/migrations/001_initial_schema.sql +226 -0
  8. package/package.json +54 -0
  9. package/service.js +143 -0
  10. package/src/agent/AgentLoop.js +553 -0
  11. package/src/agent/ContextAssembler.js +29 -0
  12. package/src/agent/KnownStore.js +254 -0
  13. package/src/agent/ProjectAgent.js +101 -0
  14. package/src/agent/ResponseHealer.js +134 -0
  15. package/src/agent/TurnExecutor.js +457 -0
  16. package/src/agent/XmlParser.js +247 -0
  17. package/src/agent/known_checks.sql +42 -0
  18. package/src/agent/known_queries.sql +80 -0
  19. package/src/agent/known_store.sql +161 -0
  20. package/src/agent/messages.js +17 -0
  21. package/src/agent/prompt_queue.sql +39 -0
  22. package/src/agent/runs.sql +114 -0
  23. package/src/agent/schemes.sql +3 -0
  24. package/src/agent/sessions.sql +51 -0
  25. package/src/agent/tokens.js +28 -0
  26. package/src/agent/turns.sql +36 -0
  27. package/src/hooks/HookRegistry.js +72 -0
  28. package/src/hooks/Hooks.js +115 -0
  29. package/src/hooks/PluginContext.js +116 -0
  30. package/src/hooks/RummyContext.js +181 -0
  31. package/src/hooks/ToolRegistry.js +83 -0
  32. package/src/llm/LlmProvider.js +107 -0
  33. package/src/llm/OllamaClient.js +88 -0
  34. package/src/llm/OpenAiClient.js +80 -0
  35. package/src/llm/OpenRouterClient.js +78 -0
  36. package/src/llm/XaiClient.js +113 -0
  37. package/src/plugins/ask_user/README.md +18 -0
  38. package/src/plugins/ask_user/ask_user.js +48 -0
  39. package/src/plugins/ask_user/docs.md +2 -0
  40. package/src/plugins/cp/README.md +18 -0
  41. package/src/plugins/cp/cp.js +55 -0
  42. package/src/plugins/cp/docs.md +2 -0
  43. package/src/plugins/current/README.md +14 -0
  44. package/src/plugins/current/current.js +48 -0
  45. package/src/plugins/engine/README.md +12 -0
  46. package/src/plugins/engine/engine.sql +18 -0
  47. package/src/plugins/engine/turn_context.sql +51 -0
  48. package/src/plugins/env/README.md +14 -0
  49. package/src/plugins/env/docs.md +2 -0
  50. package/src/plugins/env/env.js +32 -0
  51. package/src/plugins/file/README.md +25 -0
  52. package/src/plugins/file/file.js +85 -0
  53. package/src/plugins/get/README.md +19 -0
  54. package/src/plugins/get/docs.md +6 -0
  55. package/src/plugins/get/get.js +53 -0
  56. package/src/plugins/hedberg/README.md +72 -0
  57. package/src/plugins/hedberg/docs.md +9 -0
  58. package/src/plugins/hedberg/edits.js +65 -0
  59. package/src/plugins/hedberg/hedberg.js +89 -0
  60. package/src/plugins/hedberg/matcher.js +181 -0
  61. package/src/plugins/hedberg/normalize.js +41 -0
  62. package/src/plugins/hedberg/patterns.js +452 -0
  63. package/src/plugins/hedberg/sed.js +48 -0
  64. package/src/plugins/helpers.js +22 -0
  65. package/src/plugins/index.js +180 -0
  66. package/src/plugins/instructions/README.md +11 -0
  67. package/src/plugins/instructions/instructions.js +37 -0
  68. package/src/plugins/instructions/preamble.md +12 -0
  69. package/src/plugins/known/README.md +18 -0
  70. package/src/plugins/known/docs.md +3 -0
  71. package/src/plugins/known/known.js +57 -0
  72. package/src/plugins/mv/README.md +18 -0
  73. package/src/plugins/mv/docs.md +2 -0
  74. package/src/plugins/mv/mv.js +56 -0
  75. package/src/plugins/previous/README.md +15 -0
  76. package/src/plugins/previous/previous.js +50 -0
  77. package/src/plugins/progress/README.md +17 -0
  78. package/src/plugins/progress/progress.js +44 -0
  79. package/src/plugins/prompt/README.md +16 -0
  80. package/src/plugins/prompt/prompt.js +45 -0
  81. package/src/plugins/rm/README.md +18 -0
  82. package/src/plugins/rm/docs.md +4 -0
  83. package/src/plugins/rm/rm.js +51 -0
  84. package/src/plugins/rpc/README.md +45 -0
  85. package/src/plugins/rpc/rpc.js +587 -0
  86. package/src/plugins/set/README.md +32 -0
  87. package/src/plugins/set/docs.md +4 -0
  88. package/src/plugins/set/set.js +268 -0
  89. package/src/plugins/sh/README.md +18 -0
  90. package/src/plugins/sh/docs.md +2 -0
  91. package/src/plugins/sh/sh.js +32 -0
  92. package/src/plugins/skills/README.md +25 -0
  93. package/src/plugins/skills/skills.js +175 -0
  94. package/src/plugins/store/README.md +20 -0
  95. package/src/plugins/store/docs.md +5 -0
  96. package/src/plugins/store/store.js +52 -0
  97. package/src/plugins/summarize/README.md +18 -0
  98. package/src/plugins/summarize/docs.md +4 -0
  99. package/src/plugins/summarize/summarize.js +24 -0
  100. package/src/plugins/telemetry/README.md +19 -0
  101. package/src/plugins/telemetry/rpc_log.sql +28 -0
  102. package/src/plugins/telemetry/telemetry.js +186 -0
  103. package/src/plugins/unknown/README.md +23 -0
  104. package/src/plugins/unknown/docs.md +5 -0
  105. package/src/plugins/unknown/unknown.js +31 -0
  106. package/src/plugins/update/README.md +18 -0
  107. package/src/plugins/update/docs.md +4 -0
  108. package/src/plugins/update/update.js +24 -0
  109. package/src/server/ClientConnection.js +228 -0
  110. package/src/server/RpcRegistry.js +52 -0
  111. package/src/server/SocketServer.js +43 -0
  112. package/src/sql/file_constraints.sql +15 -0
  113. package/src/sql/functions/countTokens.js +7 -0
  114. package/src/sql/functions/hedmatch.js +8 -0
  115. package/src/sql/functions/hedreplace.js +8 -0
  116. package/src/sql/functions/hedsearch.js +8 -0
  117. package/src/sql/functions/schemeOf.js +7 -0
  118. package/src/sql/functions/slugify.js +6 -0
  119. package/src/sql/v_model_context.sql +101 -0
  120. package/src/sql/v_run_log.sql +23 -0
@@ -0,0 +1,457 @@
1
+ import RummyContext from "../hooks/RummyContext.js";
2
+ import ContextAssembler from "./ContextAssembler.js";
3
+ import KnownStore from "./KnownStore.js";
4
+ import msg from "./messages.js";
5
+ import ResponseHealer from "./ResponseHealer.js";
6
+ import { countTokens } from "./tokens.js";
7
+ import XmlParser from "./XmlParser.js";
8
+
9
+ export default class TurnExecutor {
10
+ #db;
11
+ #llmProvider;
12
+ #hooks;
13
+ #knownStore;
14
+
15
+ constructor(db, llmProvider, hooks, knownStore) {
16
+ this.#db = db;
17
+ this.#llmProvider = llmProvider;
18
+ this.#hooks = hooks;
19
+ this.#knownStore = knownStore;
20
+ }
21
+
22
+ async execute({
23
+ mode,
24
+ project,
25
+ projectId,
26
+ currentRunId,
27
+ currentAlias,
28
+ requestedModel,
29
+ loopPrompt,
30
+ noContext,
31
+ contextSize,
32
+ options,
33
+ signal,
34
+ }) {
35
+ const turn = await this.#knownStore.nextTurn(currentRunId);
36
+
37
+ const turnRow = await this.#db.create_turn.get({
38
+ run_id: currentRunId,
39
+ sequence: turn,
40
+ });
41
+
42
+ const unresolved = await this.#knownStore.getUnresolved(currentRunId);
43
+ if (unresolved.length > 0) {
44
+ throw new Error(
45
+ msg("error.unresolved_proposed", { count: unresolved.length }),
46
+ );
47
+ }
48
+
49
+ // Build RummyContext before turn.started so plugins can write entries
50
+ const rummy = new RummyContext(
51
+ {
52
+ tag: "turn",
53
+ attrs: {},
54
+ content: null,
55
+ children: [
56
+ { tag: "system", attrs: {}, content: null, children: [] },
57
+ { tag: "context", attrs: {}, content: null, children: [] },
58
+ { tag: "user", attrs: {}, content: null, children: [] },
59
+ { tag: "assistant", attrs: {}, content: null, children: [] },
60
+ ],
61
+ },
62
+ {
63
+ hooks: this.#hooks,
64
+ db: this.#db,
65
+ store: this.#knownStore,
66
+ project,
67
+ type: mode,
68
+ sequence: turn,
69
+ runId: currentRunId,
70
+ turnId: turnRow.id,
71
+ noContext,
72
+ contextSize,
73
+ systemPrompt: null,
74
+ loopPrompt,
75
+ },
76
+ );
77
+ // Plugins write prompt/progress/instructions entries
78
+ await this.#hooks.turn.started.emit({
79
+ rummy,
80
+ mode,
81
+ prompt: loopPrompt,
82
+ isContinuation: options?.isContinuation,
83
+ });
84
+
85
+ await this.#hooks.processTurn(rummy);
86
+
87
+ // Project instructions://system through the instructions tool's projection
88
+ const instrEntry = await this.#knownStore.getEntriesByPattern(
89
+ currentRunId,
90
+ "instructions://system",
91
+ null,
92
+ );
93
+ const instrAttrs = instrEntry[0]
94
+ ? await this.#knownStore.getAttributes(
95
+ currentRunId,
96
+ "instructions://system",
97
+ )
98
+ : null;
99
+ const systemPrompt = await this.#hooks.tools.view("instructions", {
100
+ path: "instructions://system",
101
+ scheme: "instructions",
102
+ body: instrEntry[0]?.body || "",
103
+ attributes: instrAttrs,
104
+ fidelity: "full",
105
+ category: "system",
106
+ });
107
+
108
+ // Materialize turn_context: VIEW rows projected through tools
109
+ await this.#db.clear_turn_context.run({ run_id: currentRunId, turn });
110
+ const viewRows = await this.#db.get_model_context.all({
111
+ run_id: currentRunId,
112
+ });
113
+ for (const row of viewRows) {
114
+ const scheme = row.scheme || "file";
115
+ const projectedBody = await this.#hooks.tools.view(scheme, {
116
+ path: row.path,
117
+ scheme,
118
+ body: row.body,
119
+ attributes: row.attributes ? JSON.parse(row.attributes) : null,
120
+ fidelity: row.fidelity,
121
+ category: row.category,
122
+ });
123
+
124
+ await this.#db.insert_turn_context.run({
125
+ run_id: currentRunId,
126
+ turn,
127
+ ordinal: row.ordinal,
128
+ path: row.path,
129
+ fidelity: row.fidelity,
130
+ state: row.state,
131
+ body: projectedBody ?? "",
132
+ tokens: countTokens(projectedBody ?? ""),
133
+ attributes: row.attributes,
134
+ category: row.category,
135
+ source_turn: row.turn,
136
+ });
137
+ }
138
+
139
+ await this.#hooks.run.progress.emit({
140
+ projectId,
141
+ run: currentAlias,
142
+ turn,
143
+ status: "thinking",
144
+ });
145
+
146
+ // Assemble messages from projected system prompt + materialized turn_context
147
+ const rows = await this.#db.get_turn_context.all({
148
+ run_id: currentRunId,
149
+ turn,
150
+ });
151
+ const messages = await ContextAssembler.assembleFromTurnContext(
152
+ rows,
153
+ {
154
+ type: mode,
155
+ systemPrompt,
156
+ contextSize,
157
+ },
158
+ this.#hooks,
159
+ );
160
+
161
+ const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
162
+ model: requestedModel,
163
+ projectId,
164
+ runId: currentRunId,
165
+ });
166
+
167
+ // Store assembled messages as audit
168
+ // Call LLM
169
+ await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
170
+ const rawResult = await this.#llmProvider.completion(
171
+ filteredMessages,
172
+ requestedModel,
173
+ { temperature: options?.temperature, signal },
174
+ );
175
+ const result = await this.#hooks.llm.response.filter(rawResult, {
176
+ model: requestedModel,
177
+ projectId,
178
+ runId: currentRunId,
179
+ });
180
+ await this.#hooks.llm.request.completed.emit({
181
+ model: requestedModel,
182
+ turn,
183
+ usage: result.usage,
184
+ });
185
+ const responseMessage = result.choices?.[0]?.message;
186
+ const content = responseMessage?.content || "";
187
+
188
+ await this.#hooks.run.progress.emit({
189
+ projectId,
190
+ run: currentAlias,
191
+ turn,
192
+ status: "processing",
193
+ });
194
+
195
+ // Parse and emit — plugins handle audit storage
196
+ const { commands, unparsed } = XmlParser.parse(content);
197
+
198
+ const systemMsg = filteredMessages.find((m) => m.role === "system");
199
+ const userMsg = filteredMessages.find((m) => m.role === "user");
200
+ await this.#hooks.turn.response.emit({
201
+ rummy,
202
+ turn,
203
+ result,
204
+ responseMessage,
205
+ content,
206
+ commands,
207
+ unparsed,
208
+ systemMsg: systemMsg?.content,
209
+ userMsg: userMsg?.content,
210
+ });
211
+
212
+ // --- PHASE 1: RECORD ---
213
+ // Every command becomes an entry. No execution yet.
214
+
215
+ const recorded = [];
216
+ let summaryText = null;
217
+ let updateText = null;
218
+
219
+ for (const cmd of commands) {
220
+ const entry = await this.#record(currentRunId, turn, mode, cmd);
221
+ if (!entry) continue;
222
+
223
+ if (entry.scheme === "summarize") summaryText = entry.body;
224
+ else if (entry.scheme === "update") updateText = entry.body;
225
+ else recorded.push(entry);
226
+ }
227
+
228
+ // If model sent both, summary wins
229
+ if (summaryText && updateText) updateText = null;
230
+
231
+ // If model sent neither, heal from content
232
+ let statusHealed = false;
233
+ if (!summaryText && !updateText) {
234
+ const healed = ResponseHealer.healStatus(content, commands);
235
+ summaryText = healed.summaryText;
236
+ updateText = healed.updateText;
237
+ statusHealed = true;
238
+ }
239
+
240
+ // Record healed status
241
+ if (summaryText) {
242
+ const summaryPath = await this.#knownStore.slugPath(
243
+ currentRunId,
244
+ "summarize",
245
+ summaryText,
246
+ );
247
+ await this.#knownStore.upsert(
248
+ currentRunId,
249
+ turn,
250
+ summaryPath,
251
+ summaryText,
252
+ "summary",
253
+ );
254
+ } else if (updateText) {
255
+ const updatePath = await this.#knownStore.slugPath(
256
+ currentRunId,
257
+ "update",
258
+ updateText,
259
+ );
260
+ await this.#knownStore.upsert(
261
+ currentRunId,
262
+ turn,
263
+ updatePath,
264
+ updateText,
265
+ "info",
266
+ );
267
+ }
268
+
269
+ // --- PHASE 2: DISPATCH ---
270
+ // Handlers perform side effects: promote, demote, patch, propose.
271
+
272
+ let hasErrors = false;
273
+ for (const entry of recorded) {
274
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
275
+ await this.#hooks.entry.created.emit(entry);
276
+ }
277
+
278
+ // Materialize proposals (e.g. file plugin applies accumulated revisions)
279
+ await this.#hooks.turn.proposing.emit({ rummy, recorded });
280
+
281
+ // Check if any dispatched entries ended in error state
282
+ for (const entry of recorded) {
283
+ const row = await this.#db.get_entry_state.get({
284
+ run_id: currentRunId,
285
+ path: entry.resultPath || entry.path,
286
+ });
287
+ if (row?.state === "error") hasErrors = true;
288
+ }
289
+
290
+ // Errors override summarize — the model thinks it's done but it's not
291
+ if (hasErrors && summaryText) {
292
+ summaryText = null;
293
+ updateText = "Tool errors detected — retry or investigate.";
294
+ }
295
+
296
+ // --- Classify for return value ---
297
+
298
+ const actionCalls = recorded.filter((e) =>
299
+ ["get", "store", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
300
+ e.scheme,
301
+ ),
302
+ );
303
+ const writeCalls = recorded.filter(
304
+ (e) =>
305
+ e.scheme === "known" ||
306
+ (e.scheme === "set" && !e.attributes?.blocks && !e.attributes?.search),
307
+ );
308
+ const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
309
+
310
+ const hasAct = actionCalls.some((c) =>
311
+ ["set", "rm", "sh", "mv", "cp"].includes(c.scheme),
312
+ );
313
+ const hasReads = actionCalls.some((c) =>
314
+ ["get", "env", "search"].includes(c.scheme),
315
+ );
316
+ const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
317
+ const flags = { hasAct, hasReads, hasWrites };
318
+
319
+ const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
320
+
321
+ return {
322
+ turn,
323
+ turnId: turnRow.id,
324
+ actionCalls,
325
+ writeCalls,
326
+ unknownCalls,
327
+ summaryText,
328
+ updateText,
329
+ statusHealed,
330
+ askUserCmd: askUserEntry || null,
331
+ flags,
332
+ model: result.model || requestedModel,
333
+ modelAlias: requestedModel,
334
+ temperature:
335
+ options?.temperature ??
336
+ Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
337
+ contextSize,
338
+ usage: result.usage,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Record a parsed command as a known_entries row.
344
+ * Returns the recorded entry descriptor, or null if rejected/skipped.
345
+ */
346
+ async #record(runId, turn, mode, cmd) {
347
+ // Mode enforcement — reject prohibited commands in ask mode
348
+ if (mode === "ask") {
349
+ if (cmd.name === "sh") {
350
+ console.warn("[RUMMY] Rejected <sh> in ask mode");
351
+ return null;
352
+ }
353
+ if (cmd.name === "set" && cmd.path) {
354
+ const scheme = KnownStore.scheme(cmd.path);
355
+ if (scheme === null) {
356
+ console.warn(`[RUMMY] Rejected file set to ${cmd.path} in ask mode`);
357
+ return null;
358
+ }
359
+ }
360
+ if (cmd.name === "rm" && cmd.path) {
361
+ const scheme = KnownStore.scheme(cmd.path);
362
+ if (scheme === null) {
363
+ console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
364
+ return null;
365
+ }
366
+ }
367
+ if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
368
+ const destScheme = KnownStore.scheme(cmd.to);
369
+ if (destScheme === null) {
370
+ console.warn(
371
+ `[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
372
+ );
373
+ return null;
374
+ }
375
+ }
376
+ }
377
+
378
+ const scheme = cmd.name;
379
+
380
+ // Structural tags — record and return (no handler dispatch)
381
+ if (scheme === "summarize" || scheme === "update") {
382
+ return { scheme, body: cmd.body, resultPath: null, attributes: null };
383
+ }
384
+
385
+ // Unknown — deduplicated, sticky
386
+ if (scheme === "unknown") {
387
+ const existingValues = await this.#knownStore.getUnknownValues(runId);
388
+ if (existingValues.has(cmd.body)) return null;
389
+ const unknownPath = await this.#knownStore.slugPath(
390
+ runId,
391
+ "unknown",
392
+ cmd.body,
393
+ );
394
+ await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, "full");
395
+ return {
396
+ scheme,
397
+ path: unknownPath,
398
+ body: cmd.body,
399
+ resultPath: unknownPath,
400
+ attributes: null,
401
+ };
402
+ }
403
+
404
+ // Normalize path — encode spaces in scheme:// paths
405
+ const rawTarget = cmd.path || cmd.command || cmd.question || "";
406
+ const target = rawTarget.includes("://")
407
+ ? rawTarget.replace(
408
+ /:\/\/(.*)$/,
409
+ (_, rest) => `://${encodeURIComponent(decodeURIComponent(rest))}`,
410
+ )
411
+ : rawTarget;
412
+ const resultPath = await this.#knownStore.dedup(runId, scheme, target);
413
+
414
+ // Pass parsed command fields through as attributes
415
+ const { name: _, ...attributes } = cmd;
416
+ if (cmd.path) attributes.path = target;
417
+
418
+ // known tool or naked write → known:// slug from body
419
+ if (scheme === "known" || (scheme === "set" && !cmd.path)) {
420
+ if (!cmd.body) return null;
421
+ const knownPath =
422
+ cmd.path || (await this.#knownStore.slugPath(runId, "known", cmd.body));
423
+ await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, "full");
424
+ return {
425
+ scheme: "known",
426
+ path: knownPath,
427
+ body: cmd.body,
428
+ resultPath: knownPath,
429
+ attributes,
430
+ };
431
+ }
432
+
433
+ // Record the entry
434
+ const body = cmd.body || cmd.command || cmd.question || "";
435
+ const state = this.#initialState(scheme);
436
+ await this.#knownStore.upsert(runId, turn, resultPath, body, state, {
437
+ attributes,
438
+ });
439
+
440
+ return {
441
+ scheme,
442
+ path: resultPath,
443
+ body,
444
+ attributes,
445
+ state,
446
+ resultPath,
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Initial state for a recorded command entry.
452
+ * All entries start at "full". Handlers change state during dispatch.
453
+ */
454
+ #initialState(_scheme) {
455
+ return "full";
456
+ }
457
+ }
@@ -0,0 +1,247 @@
1
+ import { Parser } from "htmlparser2";
2
+ import { parseEditContent } from "../plugins/hedberg/edits.js";
3
+ import { normalizeAttrs } from "../plugins/hedberg/normalize.js";
4
+ import { parseSed } from "../plugins/hedberg/sed.js";
5
+
6
+ const STORE_TOOLS = new Set([
7
+ "get",
8
+ "store",
9
+ "rm",
10
+ "set",
11
+ "mv",
12
+ "cp",
13
+ "search",
14
+ ]);
15
+ const ALL_TOOLS = new Set([
16
+ ...STORE_TOOLS,
17
+ "known",
18
+ "sh",
19
+ "env",
20
+ "ask_user",
21
+ "summarize",
22
+ "update",
23
+ "unknown",
24
+ ]);
25
+
26
+ /**
27
+ * Resolve the competing attr-vs-body philosophies per tool.
28
+ * If the canonical attribute is missing, the body fills it. Silent.
29
+ */
30
+ function resolveCommand(name, attrs, rawBody) {
31
+ const a = normalizeAttrs(attrs);
32
+ const trimmed = rawBody.trim();
33
+
34
+ if (name === "set") {
35
+ // Structured edit detection — merge conflict, udiff, Claude XML
36
+ const hasEdit =
37
+ /<{3,12} SEARCH/.test(trimmed) ||
38
+ />{3,12} REPLACE/.test(trimmed) ||
39
+ (trimmed.includes("@@") &&
40
+ (trimmed.includes("\n-") || trimmed.includes("\n+"))) ||
41
+ trimmed.includes("<old_text>");
42
+ if (hasEdit) {
43
+ const blocks = parseEditContent(rawBody);
44
+ if (blocks.length > 0) {
45
+ return {
46
+ name,
47
+ path: a.path,
48
+ body: a.body,
49
+ preview: a.preview,
50
+ blocks,
51
+ };
52
+ }
53
+ }
54
+ // JSON-style { search, replace } — accept valid JSON and =style variants
55
+ if (trimmed.startsWith("{") && /search/.test(trimmed)) {
56
+ let search = null;
57
+ let replace = null;
58
+ try {
59
+ const json = JSON.parse(trimmed);
60
+ search = json.search;
61
+ replace = json.replace ?? "";
62
+ } catch {
63
+ // Try = style: { search="old", replace="new" }
64
+ const searchMatch = trimmed.match(/search\s*=\s*"([^"]*)"/);
65
+ const replaceMatch = trimmed.match(/replace\s*=\s*"([^"]*)"/);
66
+ if (searchMatch) {
67
+ search = searchMatch[1];
68
+ replace = replaceMatch?.[1] ?? "";
69
+ }
70
+ }
71
+ if (search != null) {
72
+ return {
73
+ name,
74
+ path: a.path,
75
+ search,
76
+ replace,
77
+ };
78
+ }
79
+ }
80
+ // Sed syntax: s/search/replace/flags — supports chained commands
81
+ if (trimmed.startsWith("s/")) {
82
+ const blocks = parseSed(trimmed);
83
+ if (blocks?.length === 1) {
84
+ return {
85
+ name,
86
+ path: a.path,
87
+ search: blocks[0].search,
88
+ replace: blocks[0].replace,
89
+ flags: blocks[0].flags,
90
+ sed: true,
91
+ };
92
+ }
93
+ if (blocks?.length > 1) {
94
+ return { name, path: a.path, blocks };
95
+ }
96
+ }
97
+ // search+replace attrs → attribute edit mode
98
+ if (a.search) {
99
+ const replace = a.replace ?? trimmed;
100
+ return {
101
+ name,
102
+ path: a.path,
103
+ body: a.body,
104
+ preview: a.preview,
105
+ search: a.search,
106
+ replace,
107
+ };
108
+ }
109
+ // Body attr + body content → search/replace (attr is search, body is replace)
110
+ if (trimmed && a.body) {
111
+ return {
112
+ name,
113
+ path: a.path,
114
+ search: a.body,
115
+ replace: trimmed,
116
+ preview: a.preview,
117
+ };
118
+ }
119
+ // Plain write → create/overwrite
120
+ const body = trimmed || a.body || "";
121
+ return { name, path: a.path, body, preview: a.preview };
122
+ }
123
+
124
+ if (name === "summarize" || name === "update" || name === "unknown") {
125
+ const body = trimmed || a.body || "";
126
+ return { name, body };
127
+ }
128
+
129
+ if (name === "known") {
130
+ const body = trimmed || a.body || "";
131
+ const path = a.path || null;
132
+ return { name, path, body };
133
+ }
134
+
135
+ if (name === "get" || name === "store" || name === "rm") {
136
+ const path = a.path || trimmed || null;
137
+ return { name, path, body: a.body, preview: a.preview };
138
+ }
139
+
140
+ if (name === "search") {
141
+ const path = a.path || trimmed || null;
142
+ const results = a.results ? Number(a.results) : null;
143
+ return { name, path, results };
144
+ }
145
+
146
+ if (name === "mv" || name === "cp") {
147
+ const to = a.to || trimmed || null;
148
+ return { name, path: a.path, to };
149
+ }
150
+
151
+ if (name === "sh" || name === "env") {
152
+ const command = a.command || trimmed || null;
153
+ return { name, command };
154
+ }
155
+
156
+ if (name === "ask_user") {
157
+ const question = a.question || null;
158
+ const options = a.options || trimmed || null;
159
+ return { name, question, options };
160
+ }
161
+
162
+ return { name, ...a, body: trimmed || a.body };
163
+ }
164
+
165
+ export default class XmlParser {
166
+ /**
167
+ * Parse tool commands from model content using htmlparser2.
168
+ * Handles malformed XML gracefully — unclosed tags, missing slashes, etc.
169
+ * Every tool can appear as self-closing (attrs only) or with body content.
170
+ * Competing attr-vs-body philosophies are resolved silently.
171
+ * @param {string} content - Raw model response text
172
+ * @returns {{ commands: Array, warnings: string[], unparsed: string }}
173
+ */
174
+ static parse(content) {
175
+ if (!content) return { commands: [], warnings: [], unparsed: "" };
176
+
177
+ const commands = [];
178
+ const warnings = [];
179
+ const textChunks = [];
180
+ let current = null;
181
+ let ended = false;
182
+
183
+ const parser = new Parser(
184
+ {
185
+ onopentag(name, attrs) {
186
+ if (!ALL_TOOLS.has(name)) {
187
+ if (current) {
188
+ current.rawBody += `<${name}>`;
189
+ }
190
+ return;
191
+ }
192
+
193
+ current = { name, attrs, rawBody: "" };
194
+ },
195
+
196
+ ontext(text) {
197
+ if (current) {
198
+ current.rawBody += text;
199
+ } else {
200
+ textChunks.push(text);
201
+ }
202
+ },
203
+
204
+ onclosetag(name, isImplied) {
205
+ if (current && name === current.name) {
206
+ if (ended) {
207
+ warnings.push(`Unclosed <${name}> tag — content captured anyway`);
208
+ }
209
+ commands.push(
210
+ resolveCommand(current.name, current.attrs, current.rawBody),
211
+ );
212
+ current = null;
213
+ } else if (current) {
214
+ current.rawBody += `</${name}>`;
215
+ } else if (isImplied && ALL_TOOLS.has(name)) {
216
+ // Self-closing tag that htmlparser2 auto-closed
217
+ }
218
+ },
219
+
220
+ onerror(err) {
221
+ warnings.push(`Parse error: ${err.message}`);
222
+ },
223
+ },
224
+ {
225
+ recognizeSelfClosing: true,
226
+ lowerCaseTags: true,
227
+ lowerCaseAttributeNames: true,
228
+ },
229
+ );
230
+
231
+ parser.write(content);
232
+ ended = true;
233
+ parser.end();
234
+
235
+ // Flush any unclosed tool tag
236
+ if (current) {
237
+ warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
238
+ commands.push(
239
+ resolveCommand(current.name, current.attrs, current.rawBody),
240
+ );
241
+ current = null;
242
+ }
243
+
244
+ const unparsed = textChunks.join("").trim();
245
+ return { commands, warnings, unparsed };
246
+ }
247
+ }