@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.
- package/.env.example +55 -0
- package/LICENSE +21 -0
- package/PLUGINS.md +302 -0
- package/README.md +41 -0
- package/SPEC.md +524 -0
- package/lang/en.json +34 -0
- package/migrations/001_initial_schema.sql +226 -0
- package/package.json +54 -0
- package/service.js +143 -0
- package/src/agent/AgentLoop.js +553 -0
- package/src/agent/ContextAssembler.js +29 -0
- package/src/agent/KnownStore.js +254 -0
- package/src/agent/ProjectAgent.js +101 -0
- package/src/agent/ResponseHealer.js +134 -0
- package/src/agent/TurnExecutor.js +457 -0
- package/src/agent/XmlParser.js +247 -0
- package/src/agent/known_checks.sql +42 -0
- package/src/agent/known_queries.sql +80 -0
- package/src/agent/known_store.sql +161 -0
- package/src/agent/messages.js +17 -0
- package/src/agent/prompt_queue.sql +39 -0
- package/src/agent/runs.sql +114 -0
- package/src/agent/schemes.sql +3 -0
- package/src/agent/sessions.sql +51 -0
- package/src/agent/tokens.js +28 -0
- package/src/agent/turns.sql +36 -0
- package/src/hooks/HookRegistry.js +72 -0
- package/src/hooks/Hooks.js +115 -0
- package/src/hooks/PluginContext.js +116 -0
- package/src/hooks/RummyContext.js +181 -0
- package/src/hooks/ToolRegistry.js +83 -0
- package/src/llm/LlmProvider.js +107 -0
- package/src/llm/OllamaClient.js +88 -0
- package/src/llm/OpenAiClient.js +80 -0
- package/src/llm/OpenRouterClient.js +78 -0
- package/src/llm/XaiClient.js +113 -0
- package/src/plugins/ask_user/README.md +18 -0
- package/src/plugins/ask_user/ask_user.js +48 -0
- package/src/plugins/ask_user/docs.md +2 -0
- package/src/plugins/cp/README.md +18 -0
- package/src/plugins/cp/cp.js +55 -0
- package/src/plugins/cp/docs.md +2 -0
- package/src/plugins/current/README.md +14 -0
- package/src/plugins/current/current.js +48 -0
- package/src/plugins/engine/README.md +12 -0
- package/src/plugins/engine/engine.sql +18 -0
- package/src/plugins/engine/turn_context.sql +51 -0
- package/src/plugins/env/README.md +14 -0
- package/src/plugins/env/docs.md +2 -0
- package/src/plugins/env/env.js +32 -0
- package/src/plugins/file/README.md +25 -0
- package/src/plugins/file/file.js +85 -0
- package/src/plugins/get/README.md +19 -0
- package/src/plugins/get/docs.md +6 -0
- package/src/plugins/get/get.js +53 -0
- package/src/plugins/hedberg/README.md +72 -0
- package/src/plugins/hedberg/docs.md +9 -0
- package/src/plugins/hedberg/edits.js +65 -0
- package/src/plugins/hedberg/hedberg.js +89 -0
- package/src/plugins/hedberg/matcher.js +181 -0
- package/src/plugins/hedberg/normalize.js +41 -0
- package/src/plugins/hedberg/patterns.js +452 -0
- package/src/plugins/hedberg/sed.js +48 -0
- package/src/plugins/helpers.js +22 -0
- package/src/plugins/index.js +180 -0
- package/src/plugins/instructions/README.md +11 -0
- package/src/plugins/instructions/instructions.js +37 -0
- package/src/plugins/instructions/preamble.md +12 -0
- package/src/plugins/known/README.md +18 -0
- package/src/plugins/known/docs.md +3 -0
- package/src/plugins/known/known.js +57 -0
- package/src/plugins/mv/README.md +18 -0
- package/src/plugins/mv/docs.md +2 -0
- package/src/plugins/mv/mv.js +56 -0
- package/src/plugins/previous/README.md +15 -0
- package/src/plugins/previous/previous.js +50 -0
- package/src/plugins/progress/README.md +17 -0
- package/src/plugins/progress/progress.js +44 -0
- package/src/plugins/prompt/README.md +16 -0
- package/src/plugins/prompt/prompt.js +45 -0
- package/src/plugins/rm/README.md +18 -0
- package/src/plugins/rm/docs.md +4 -0
- package/src/plugins/rm/rm.js +51 -0
- package/src/plugins/rpc/README.md +45 -0
- package/src/plugins/rpc/rpc.js +587 -0
- package/src/plugins/set/README.md +32 -0
- package/src/plugins/set/docs.md +4 -0
- package/src/plugins/set/set.js +268 -0
- package/src/plugins/sh/README.md +18 -0
- package/src/plugins/sh/docs.md +2 -0
- package/src/plugins/sh/sh.js +32 -0
- package/src/plugins/skills/README.md +25 -0
- package/src/plugins/skills/skills.js +175 -0
- package/src/plugins/store/README.md +20 -0
- package/src/plugins/store/docs.md +5 -0
- package/src/plugins/store/store.js +52 -0
- package/src/plugins/summarize/README.md +18 -0
- package/src/plugins/summarize/docs.md +4 -0
- package/src/plugins/summarize/summarize.js +24 -0
- package/src/plugins/telemetry/README.md +19 -0
- package/src/plugins/telemetry/rpc_log.sql +28 -0
- package/src/plugins/telemetry/telemetry.js +186 -0
- package/src/plugins/unknown/README.md +23 -0
- package/src/plugins/unknown/docs.md +5 -0
- package/src/plugins/unknown/unknown.js +31 -0
- package/src/plugins/update/README.md +18 -0
- package/src/plugins/update/docs.md +4 -0
- package/src/plugins/update/update.js +24 -0
- package/src/server/ClientConnection.js +228 -0
- package/src/server/RpcRegistry.js +52 -0
- package/src/server/SocketServer.js +43 -0
- package/src/sql/file_constraints.sql +15 -0
- package/src/sql/functions/countTokens.js +7 -0
- package/src/sql/functions/hedmatch.js +8 -0
- package/src/sql/functions/hedreplace.js +8 -0
- package/src/sql/functions/hedsearch.js +8 -0
- package/src/sql/functions/schemeOf.js +7 -0
- package/src/sql/functions/slugify.js +6 -0
- package/src/sql/v_model_context.sql +101 -0
- 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
|
+
}
|