@possumtech/rummy 0.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -4
- package/PLUGINS.md +389 -194
- package/README.md +25 -8
- package/SPEC.md +850 -373
- package/bin/demo.js +166 -0
- package/bin/rummy.js +9 -3
- package/biome/no-fallbacks.grit +50 -0
- package/lang/en.json +2 -2
- package/migrations/001_initial_schema.sql +88 -37
- package/package.json +6 -4
- package/service.js +50 -9
- package/src/agent/AgentLoop.js +460 -331
- package/src/agent/ContextAssembler.js +4 -2
- package/src/agent/Entries.js +655 -0
- package/src/agent/ProjectAgent.js +30 -18
- package/src/agent/TurnExecutor.js +232 -379
- package/src/agent/XmlParser.js +242 -67
- package/src/agent/budget.js +56 -0
- package/src/agent/errors.js +22 -0
- package/src/agent/httpStatus.js +39 -0
- package/src/agent/known_checks.sql +8 -4
- package/src/agent/known_queries.sql +9 -13
- package/src/agent/known_store.sql +275 -118
- package/src/agent/materializeContext.js +102 -0
- package/src/agent/runs.sql +10 -7
- package/src/agent/schemes.sql +14 -3
- package/src/agent/turns.sql +9 -9
- package/src/hooks/HookRegistry.js +6 -5
- package/src/hooks/Hooks.js +44 -3
- package/src/hooks/PluginContext.js +35 -21
- package/src/{server → hooks}/RpcRegistry.js +2 -1
- package/src/hooks/RummyContext.js +140 -37
- package/src/hooks/ToolRegistry.js +36 -35
- package/src/llm/LlmProvider.js +64 -90
- package/src/llm/errors.js +21 -0
- package/src/plugins/ask_user/README.md +1 -1
- package/src/plugins/ask_user/ask_user.js +37 -12
- package/src/plugins/ask_user/ask_userDoc.js +2 -23
- package/src/plugins/ask_user/ask_userDoc.md +10 -0
- package/src/plugins/budget/README.md +27 -23
- package/src/plugins/budget/budget.js +261 -69
- package/src/plugins/cp/README.md +2 -2
- package/src/plugins/cp/cp.js +31 -13
- package/src/plugins/cp/cpDoc.js +2 -23
- package/src/plugins/cp/cpDoc.md +7 -0
- package/src/plugins/engine/README.md +2 -2
- package/src/plugins/engine/engine.sql +4 -4
- package/src/plugins/engine/turn_context.sql +10 -10
- package/src/plugins/env/README.md +20 -5
- package/src/plugins/env/env.js +47 -8
- package/src/plugins/env/envDoc.js +2 -23
- package/src/plugins/env/envDoc.md +13 -0
- package/src/plugins/error/README.md +16 -0
- package/src/plugins/error/error.js +151 -0
- package/src/plugins/file/README.md +6 -6
- package/src/plugins/file/file.js +15 -7
- package/src/plugins/get/README.md +1 -1
- package/src/plugins/get/get.js +125 -49
- package/src/plugins/get/getDoc.js +2 -43
- package/src/plugins/get/getDoc.md +36 -0
- package/src/plugins/hedberg/README.md +1 -2
- package/src/plugins/hedberg/hedberg.js +8 -4
- package/src/plugins/hedberg/matcher.js +16 -17
- package/src/plugins/hedberg/normalize.js +0 -48
- package/src/plugins/helpers.js +43 -3
- package/src/plugins/index.js +146 -123
- package/src/plugins/instructions/README.md +35 -9
- package/src/plugins/instructions/instructions.js +126 -12
- package/src/plugins/instructions/instructions.md +25 -0
- package/src/plugins/instructions/instructions_104.md +7 -0
- package/src/plugins/instructions/instructions_105.md +46 -0
- package/src/plugins/instructions/instructions_106.md +0 -0
- package/src/plugins/instructions/instructions_107.md +0 -0
- package/src/plugins/instructions/instructions_108.md +8 -0
- package/src/plugins/instructions/protocol.js +12 -0
- package/src/plugins/known/README.md +2 -2
- package/src/plugins/known/known.js +77 -45
- package/src/plugins/known/knownDoc.js +2 -29
- package/src/plugins/known/knownDoc.md +8 -0
- package/src/plugins/log/README.md +48 -0
- package/src/plugins/log/log.js +109 -0
- package/src/plugins/mv/README.md +2 -2
- package/src/plugins/mv/mv.js +57 -24
- package/src/plugins/mv/mvDoc.js +2 -29
- package/src/plugins/mv/mvDoc.md +10 -0
- package/src/plugins/ollama/README.md +15 -0
- package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
- package/src/plugins/openai/README.md +17 -0
- package/src/plugins/openai/openai.js +120 -0
- package/src/plugins/openrouter/README.md +27 -0
- package/src/plugins/openrouter/openrouter.js +121 -0
- package/src/plugins/persona/README.md +20 -0
- package/src/plugins/persona/persona.js +9 -16
- package/src/plugins/policy/README.md +21 -0
- package/src/plugins/policy/policy.js +29 -14
- package/src/plugins/prompt/README.md +1 -1
- package/src/plugins/prompt/prompt.js +63 -18
- package/src/plugins/rm/README.md +1 -1
- package/src/plugins/rm/rm.js +58 -14
- package/src/plugins/rm/rmDoc.js +2 -24
- package/src/plugins/rm/rmDoc.md +13 -0
- package/src/plugins/rpc/README.md +2 -2
- package/src/plugins/rpc/rpc.js +515 -296
- package/src/plugins/set/README.md +1 -1
- package/src/plugins/set/set.js +318 -77
- package/src/plugins/set/setDoc.js +2 -35
- package/src/plugins/set/setDoc.md +22 -0
- package/src/plugins/sh/README.md +28 -5
- package/src/plugins/sh/sh.js +52 -8
- package/src/plugins/sh/shDoc.js +2 -23
- package/src/plugins/sh/shDoc.md +13 -0
- package/src/plugins/skill/README.md +23 -0
- package/src/plugins/skill/skill.js +14 -17
- package/src/plugins/stream/README.md +101 -0
- package/src/plugins/stream/stream.js +290 -0
- package/src/plugins/telemetry/README.md +1 -1
- package/src/plugins/telemetry/telemetry.js +148 -74
- package/src/plugins/think/README.md +1 -1
- package/src/plugins/think/think.js +14 -1
- package/src/plugins/think/thinkDoc.js +2 -17
- package/src/plugins/think/thinkDoc.md +7 -0
- package/src/plugins/unknown/README.md +3 -3
- package/src/plugins/unknown/unknown.js +56 -21
- package/src/plugins/unknown/unknownDoc.js +2 -25
- package/src/plugins/unknown/unknownDoc.md +11 -0
- package/src/plugins/update/README.md +1 -1
- package/src/plugins/update/update.js +67 -5
- package/src/plugins/update/updateDoc.js +2 -27
- package/src/plugins/update/updateDoc.md +8 -0
- package/src/plugins/xai/README.md +23 -0
- package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
- package/src/server/ClientConnection.js +64 -37
- package/src/server/SocketServer.js +23 -10
- package/src/server/protocol.js +11 -0
- package/src/sql/functions/slugify.js +13 -1
- package/src/sql/v_model_context.sql +27 -31
- package/src/sql/v_run_log.sql +9 -14
- package/EXCEPTIONS.md +0 -46
- package/src/agent/KnownStore.js +0 -338
- package/src/agent/ResponseHealer.js +0 -188
- package/src/llm/OpenAiClient.js +0 -100
- package/src/llm/OpenRouterClient.js +0 -100
- package/src/plugins/budget/recovery.js +0 -47
- package/src/plugins/instructions/preamble.md +0 -37
- package/src/plugins/performed/README.md +0 -15
- package/src/plugins/performed/performed.js +0 -45
- package/src/plugins/previous/README.md +0 -16
- package/src/plugins/previous/previous.js +0 -60
- package/src/plugins/progress/README.md +0 -16
- package/src/plugins/progress/progress.js +0 -26
- package/src/plugins/summarize/README.md +0 -19
- package/src/plugins/summarize/summarize.js +0 -32
- package/src/plugins/summarize/summarizeDoc.js +0 -28
package/src/agent/XmlParser.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { Parser } from "htmlparser2";
|
|
2
2
|
import { parseEditContent } from "../plugins/hedberg/edits.js";
|
|
3
|
-
import {
|
|
3
|
+
import { parseJsonEdit } from "../plugins/hedberg/normalize.js";
|
|
4
4
|
import { parseSed } from "../plugins/hedberg/sed.js";
|
|
5
5
|
|
|
6
6
|
const STORE_TOOLS = new Set(["get", "rm", "set", "mv", "cp", "search"]);
|
|
7
7
|
export const ALL_TOOLS = new Set([
|
|
8
8
|
...STORE_TOOLS,
|
|
9
|
-
"known",
|
|
10
9
|
"sh",
|
|
11
10
|
"env",
|
|
12
11
|
"ask_user",
|
|
13
|
-
"summarize",
|
|
14
12
|
"update",
|
|
15
|
-
"unknown",
|
|
16
13
|
"think",
|
|
17
14
|
]);
|
|
18
15
|
|
|
@@ -20,8 +17,7 @@ export const ALL_TOOLS = new Set([
|
|
|
20
17
|
* Resolve the competing attr-vs-body philosophies per tool.
|
|
21
18
|
* If the canonical attribute is missing, the body fills it. Silent.
|
|
22
19
|
*/
|
|
23
|
-
function resolveCommand(name,
|
|
24
|
-
const a = normalizeAttrs(attrs);
|
|
20
|
+
function resolveCommand(name, a, rawBody) {
|
|
25
21
|
const trimmed = rawBody.trim();
|
|
26
22
|
|
|
27
23
|
if (name === "set") {
|
|
@@ -88,47 +84,48 @@ function resolveCommand(name, attrs, rawBody) {
|
|
|
88
84
|
preview: a.preview,
|
|
89
85
|
};
|
|
90
86
|
}
|
|
91
|
-
// Plain write or
|
|
87
|
+
// Plain write or visibility change
|
|
92
88
|
const body = trimmed || a.body || "";
|
|
93
89
|
return { name, ...a, body };
|
|
94
90
|
}
|
|
95
91
|
|
|
96
|
-
if (name === "
|
|
92
|
+
if (name === "update") {
|
|
97
93
|
const body = trimmed || a.body || "";
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (name === "known") {
|
|
102
|
-
const body = trimmed || a.body || "";
|
|
103
|
-
const path = a.path || null;
|
|
104
|
-
return { name, ...a, path, body };
|
|
94
|
+
const status = a.status ? Number(a.status) : 102;
|
|
95
|
+
return { name, ...a, body, status };
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
if (name === "get" || name === "rm") {
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
// Spread `a` so `line`, `limit`, `visibility`, and future attrs
|
|
100
|
+
// reach the handler. Earlier narrow extraction silently dropped
|
|
101
|
+
// `line=/limit=` and stranded the partial-read path advertised
|
|
102
|
+
// in getDoc.
|
|
103
|
+
return { name, ...a, path: a.path || trimmed || null };
|
|
110
104
|
}
|
|
111
105
|
|
|
112
106
|
if (name === "search") {
|
|
113
107
|
const path = a.path || trimmed || null;
|
|
114
108
|
const results = a.results ? Number(a.results) : null;
|
|
115
|
-
return { name, path, results };
|
|
109
|
+
return { name, ...a, path, results };
|
|
116
110
|
}
|
|
117
111
|
|
|
118
112
|
if (name === "mv" || name === "cp") {
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
// Spread `a` so `visibility` reaches the handler. mvDoc
|
|
114
|
+
// advertises `<mv path="known://..." visibility="summarized"/>`
|
|
115
|
+
// for batch visibility changes and was silently stripping that
|
|
116
|
+
// attr before.
|
|
117
|
+
return { name, ...a, path: a.path, to: a.to || trimmed || null };
|
|
121
118
|
}
|
|
122
119
|
|
|
123
120
|
if (name === "sh" || name === "env") {
|
|
124
121
|
const command = a.command || trimmed || null;
|
|
125
|
-
return { name, command };
|
|
122
|
+
return { name, ...a, command };
|
|
126
123
|
}
|
|
127
124
|
|
|
128
125
|
if (name === "ask_user") {
|
|
129
126
|
const question = a.question || null;
|
|
130
127
|
const options = a.options || trimmed || null;
|
|
131
|
-
return { name, question, options };
|
|
128
|
+
return { name, ...a, question, options };
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
return { name, ...a, body: trimmed || a.body };
|
|
@@ -143,7 +140,7 @@ export default class XmlParser {
|
|
|
143
140
|
* @param {string} content - Raw model response text
|
|
144
141
|
* @returns {{ commands: Array, warnings: string[], unparsed: string }}
|
|
145
142
|
*/
|
|
146
|
-
static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS)
|
|
143
|
+
static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS);
|
|
147
144
|
|
|
148
145
|
static parse(content) {
|
|
149
146
|
if (!content) return { commands: [], warnings: [], unparsed: "" };
|
|
@@ -154,6 +151,25 @@ export default class XmlParser {
|
|
|
154
151
|
const commands = [];
|
|
155
152
|
const warnings = [];
|
|
156
153
|
const textChunks = [];
|
|
154
|
+
|
|
155
|
+
// Pre-flight: neutralize tool tags inside markdown code spans.
|
|
156
|
+
// Models quote instructions containing `<get/>` etc. — the parser
|
|
157
|
+
// would treat them as real tool calls. Replace the angle brackets
|
|
158
|
+
// inside backtick spans so htmlparser2 ignores them.
|
|
159
|
+
const codeNeutralized = XmlParser.#neutralizeCodeSpans(normalized);
|
|
160
|
+
|
|
161
|
+
// Pre-flight: fix mismatched close tags that htmlparser2 silently
|
|
162
|
+
// drops (making our onclosetag recovery code unreachable). Must run
|
|
163
|
+
// before balanceAttrQuotes since the mismatch scan needs clean tags.
|
|
164
|
+
const mismatchFixed = XmlParser.#correctMismatchedCloses(
|
|
165
|
+
codeNeutralized,
|
|
166
|
+
warnings,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Pre-flight: balance unclosed attribute quotes that would otherwise
|
|
170
|
+
// cause htmlparser2 to consume the rest of input as a single attribute
|
|
171
|
+
// value, silently dropping every subsequent tool call.
|
|
172
|
+
const balanced = XmlParser.#balanceAttrQuotes(mismatchFixed, warnings);
|
|
157
173
|
let current = null;
|
|
158
174
|
let ended = false;
|
|
159
175
|
let capped = false;
|
|
@@ -162,35 +178,48 @@ export default class XmlParser {
|
|
|
162
178
|
{
|
|
163
179
|
onopentag(name, attrs) {
|
|
164
180
|
if (capped) return;
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
|
|
182
|
+
if (current) {
|
|
183
|
+
// Empty-body case: current tool opened but got no text
|
|
184
|
+
// content before a new tag. The model likely meant current
|
|
185
|
+
// to self-close but typed it in paired form, or emitted a
|
|
186
|
+
// mismatched close tag that htmlparser2 silently dropped.
|
|
187
|
+
// Close current, open new.
|
|
188
|
+
const hasBody = current.rawBody.trim() !== "";
|
|
189
|
+
const hasNestedOpens = (current.nested || []).length > 0;
|
|
190
|
+
if (!hasBody && !hasNestedOpens && ALL_TOOLS.has(name)) {
|
|
191
|
+
warnings.push(
|
|
192
|
+
`Unclosed <${current.name}> before <${name}> — recovered`,
|
|
193
|
+
);
|
|
194
|
+
commands.push(
|
|
195
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
196
|
+
);
|
|
197
|
+
current = null;
|
|
198
|
+
} else {
|
|
199
|
+
// Nested tag inside a body with content — treat as body
|
|
200
|
+
// text. Tool bodies are opaque: the model writing a plan
|
|
201
|
+
// with <get/> in it, SEARCH/REPLACE in <set>, or XML
|
|
202
|
+
// examples in <known> all need to survive intact. Track
|
|
203
|
+
// nested opens on a stack so matching closes pop off and
|
|
204
|
+
// orphan closes (typos) still trigger recovery.
|
|
167
205
|
const attrStr = Object.entries(attrs)
|
|
168
|
-
.map(([k, v]) => v === "" ? k : `${k}="${v}"`)
|
|
206
|
+
.map(([k, v]) => (v === "" ? k : `${k}="${v}"`))
|
|
169
207
|
.join(" ");
|
|
170
|
-
current.rawBody += attrStr
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
current.rawBody += attrStr ? `<${name} ${attrStr}>` : `<${name}>`;
|
|
209
|
+
current.nested ||= [];
|
|
210
|
+
current.nested.push(name);
|
|
211
|
+
return;
|
|
173
212
|
}
|
|
174
|
-
return;
|
|
175
213
|
}
|
|
176
214
|
|
|
177
|
-
|
|
178
|
-
if (current) {
|
|
179
|
-
warnings.push(
|
|
180
|
-
`Unclosed <${current.name}> before <${name}> — recovered`,
|
|
181
|
-
);
|
|
182
|
-
commands.push(
|
|
183
|
-
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
184
|
-
);
|
|
185
|
-
}
|
|
215
|
+
if (!ALL_TOOLS.has(name)) return;
|
|
186
216
|
|
|
187
217
|
if (commands.length >= XmlParser.MAX_COMMANDS) {
|
|
188
218
|
capped = true;
|
|
189
|
-
current = null;
|
|
190
219
|
return;
|
|
191
220
|
}
|
|
192
221
|
|
|
193
|
-
current = { name, attrs, rawBody: "" };
|
|
222
|
+
current = { name, attrs, rawBody: "", nested: [] };
|
|
194
223
|
},
|
|
195
224
|
|
|
196
225
|
ontext(text) {
|
|
@@ -204,28 +233,49 @@ export default class XmlParser {
|
|
|
204
233
|
|
|
205
234
|
onclosetag(name, isImplied) {
|
|
206
235
|
if (capped) return;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
|
|
237
|
+
if (current) {
|
|
238
|
+
// Matching nested close — pop stack, keep as text.
|
|
239
|
+
const nested = current.nested;
|
|
240
|
+
if (nested.length > 0 && nested[nested.length - 1] === name) {
|
|
241
|
+
nested.pop();
|
|
242
|
+
current.rawBody += `</${name}>`;
|
|
243
|
+
return;
|
|
210
244
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
245
|
+
|
|
246
|
+
// Matching close for outer tool — finalize.
|
|
247
|
+
if (name === current.name && nested.length === 0) {
|
|
248
|
+
if (ended) {
|
|
249
|
+
warnings.push(
|
|
250
|
+
`Unclosed <${name}> tag — content captured anyway`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
commands.push(
|
|
254
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
255
|
+
);
|
|
256
|
+
current = null;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Orphan close for a known tool (likely typo) — recover.
|
|
261
|
+
if (ALL_TOOLS.has(name)) {
|
|
262
|
+
warnings.push(
|
|
263
|
+
`Mismatched </${name}> closing <${current.name}> — recovered`,
|
|
264
|
+
);
|
|
265
|
+
commands.push(
|
|
266
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
267
|
+
);
|
|
268
|
+
current = null;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Unknown orphan close — text.
|
|
226
273
|
current.rawBody += `</${name}>`;
|
|
227
|
-
|
|
228
|
-
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (isImplied && ALL_TOOLS.has(name)) {
|
|
278
|
+
// Self-closing tag that htmlparser2 auto-closed at top level
|
|
229
279
|
}
|
|
230
280
|
},
|
|
231
281
|
|
|
@@ -240,7 +290,7 @@ export default class XmlParser {
|
|
|
240
290
|
},
|
|
241
291
|
);
|
|
242
292
|
|
|
243
|
-
parser.write(
|
|
293
|
+
parser.write(balanced);
|
|
244
294
|
ended = true;
|
|
245
295
|
parser.end();
|
|
246
296
|
|
|
@@ -263,6 +313,88 @@ export default class XmlParser {
|
|
|
263
313
|
return { commands, warnings, unparsed };
|
|
264
314
|
}
|
|
265
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Repair a specific malformed-tag pattern: an attribute value opened with
|
|
318
|
+
* `="` that never closes before the next tag. Without repair, htmlparser2
|
|
319
|
+
* consumes the rest of input as one giant attribute value and silently
|
|
320
|
+
* drops every subsequent tool call.
|
|
321
|
+
*
|
|
322
|
+
* Pattern matched: <TAG ... ATTR="text-with-no-quote</NEXT>
|
|
323
|
+
* Repair: <TAG ... ATTR="text-with-no-quote"></NEXT>
|
|
324
|
+
*
|
|
325
|
+
* Conservative — only triggers when the value contains no quote, no `>`,
|
|
326
|
+
* and is followed by another tag opening or close. Well-formed input is
|
|
327
|
+
* untouched.
|
|
328
|
+
*/
|
|
329
|
+
static #balanceAttrQuotes(content, warnings) {
|
|
330
|
+
let fixes = 0;
|
|
331
|
+
const repaired = content.replace(
|
|
332
|
+
/(<\w+\s[^<>]*?\w+=")([^"<>]*?)(<\/?\w+)/g,
|
|
333
|
+
(_, opening, value, nextTag) => {
|
|
334
|
+
fixes++;
|
|
335
|
+
return `${opening}${value}">${nextTag}`;
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
if (fixes > 0) {
|
|
339
|
+
warnings.push(
|
|
340
|
+
`Repaired ${fixes} malformed attribute(s) — close all attribute values with a quote.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return repaired;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Correct mismatched close tags before htmlparser2 sees them.
|
|
348
|
+
*
|
|
349
|
+
* htmlparser2 silently drops close tags that don't match the currently
|
|
350
|
+
* open element (e.g. `<set>body</known>` — `</known>` vanishes). This
|
|
351
|
+
* makes the explicit mismatch recovery in onclosetag unreachable and
|
|
352
|
+
* causes all subsequent sibling commands to be absorbed as body text.
|
|
353
|
+
*
|
|
354
|
+
* Conservative: only corrects when the mismatch is at the outermost
|
|
355
|
+
* tool depth (stack.length === 1). Nested mismatches inside body text
|
|
356
|
+
* are left for htmlparser2 + body opacity to handle normally.
|
|
357
|
+
*/
|
|
358
|
+
/**
|
|
359
|
+
* Neutralize XML tags inside markdown code spans so the parser
|
|
360
|
+
* doesn't treat quoted tool names as real commands.
|
|
361
|
+
* `<get/>` → `<get/>` (htmlparser2 ignores entities)
|
|
362
|
+
*/
|
|
363
|
+
static #neutralizeCodeSpans(content) {
|
|
364
|
+
return content.replace(/`([^`]*)`/g, (match, inner) => {
|
|
365
|
+
if (!/<\/?[\w]/.test(inner)) return match;
|
|
366
|
+
return `\`${inner.replace(/</g, "<").replace(/>/g, ">")}\``;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
static #correctMismatchedCloses(content, warnings) {
|
|
371
|
+
const stack = [];
|
|
372
|
+
return content.replace(
|
|
373
|
+
/<(\/?)(\w+)([^>]*?)(\/?)>/g,
|
|
374
|
+
(match, slash, tag, _attrs, selfClose) => {
|
|
375
|
+
if (!ALL_TOOLS.has(tag)) return match;
|
|
376
|
+
if (selfClose === "/") return match;
|
|
377
|
+
if (slash === "/") {
|
|
378
|
+
if (stack.length === 0) return match;
|
|
379
|
+
if (stack[stack.length - 1] === tag) {
|
|
380
|
+
stack.pop();
|
|
381
|
+
return match;
|
|
382
|
+
}
|
|
383
|
+
if (stack.length === 1) {
|
|
384
|
+
const top = stack.pop();
|
|
385
|
+
warnings.push(
|
|
386
|
+
`Mismatched </${tag}> closing <${top}> — corrected to </${top}>`,
|
|
387
|
+
);
|
|
388
|
+
return `</${top}>`;
|
|
389
|
+
}
|
|
390
|
+
return match;
|
|
391
|
+
}
|
|
392
|
+
stack.push(tag);
|
|
393
|
+
return match;
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
266
398
|
/**
|
|
267
399
|
* Normalize native tool call formats to rummy XML.
|
|
268
400
|
* Models sometimes emit their training-format tool calls instead of
|
|
@@ -276,12 +408,30 @@ export default class XmlParser {
|
|
|
276
408
|
);
|
|
277
409
|
|
|
278
410
|
// Qwen/gemma: <|tool_call>call:NAME{key:"value"}<tool_call|>
|
|
411
|
+
// NAME may be namespaced with any of /, :, or . separators
|
|
412
|
+
// (e.g. `rummy.nvim/get`, `rummy:get`) — extract the trailing word
|
|
413
|
+
// sequence as the tool name. Value forms observed in the wild:
|
|
414
|
+
// key="v" / key:"v" / key:v (unquoted) / key:<|"|>v<|"|> (gemma chat-quotes)
|
|
279
415
|
result = result.replace(
|
|
280
|
-
/<\|tool_call>call:(\w+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
|
|
281
|
-
(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
416
|
+
/<\|tool_call>call:([\w.:/-]+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
|
|
417
|
+
(match, qualifiedName, params) => {
|
|
418
|
+
const name = qualifiedName.match(/\w+$/)?.[0] ?? qualifiedName;
|
|
419
|
+
if (!ALL_TOOLS.has(name)) {
|
|
420
|
+
return `<error>Unknown command '${qualifiedName}' in <|tool_call> format. Use XML commands listed above.</error>`;
|
|
421
|
+
}
|
|
422
|
+
const valueMatch = params.match(
|
|
423
|
+
/[=:]\s*(?:<\|"\|>([^<]*?)<\|"\|>|"([^"]*)"|'([^']*)'|([^,}]+))/,
|
|
424
|
+
);
|
|
425
|
+
const body = (
|
|
426
|
+
valueMatch?.[1] ??
|
|
427
|
+
valueMatch?.[2] ??
|
|
428
|
+
valueMatch?.[3] ??
|
|
429
|
+
valueMatch?.[4] ??
|
|
430
|
+
""
|
|
431
|
+
).trim();
|
|
432
|
+
if (!body) {
|
|
433
|
+
return `<error>Could not extract argument from <|tool_call> ${match}. Use XML format like <${name}>value</${name}>.</error>`;
|
|
434
|
+
}
|
|
285
435
|
return `<${name}>${body}</${name}>`;
|
|
286
436
|
},
|
|
287
437
|
);
|
|
@@ -319,6 +469,31 @@ export default class XmlParser {
|
|
|
319
469
|
},
|
|
320
470
|
);
|
|
321
471
|
|
|
472
|
+
// Catch-all: any remaining <|tool_call> tokens are malformed native
|
|
473
|
+
// attempts (no {} block, missing close, wrong shape entirely). Replace
|
|
474
|
+
// each with an <error> so the model gets feedback on its next turn and
|
|
475
|
+
// learns to switch to XML. Lazy-match up to the next native close, the
|
|
476
|
+
// next XML close tag, or end of input — preserves any trailing valid XML.
|
|
477
|
+
// Error body must NOT contain literal <get>/<set>/etc. — those would
|
|
478
|
+
// re-enter the parser as phantom tool calls. Describe the format in
|
|
479
|
+
// prose instead and point at the tool docs above.
|
|
480
|
+
result = result.replace(
|
|
481
|
+
/<\|tool_call>[\s\S]*?(?:<\|?tool_call\|?>|<\/\w+>|$)/g,
|
|
482
|
+
() =>
|
|
483
|
+
"<error>Native tool call format not supported. Use the XML commands listed above (e.g. a get tag with a path attribute, or a set tag with path and body).</error>",
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Strip any orphan chat-format quote tokens left after replacement.
|
|
487
|
+
result = result.replace(/<\|"\|>/g, '"');
|
|
488
|
+
|
|
489
|
+
// Gemma sometimes leaks OpenAI-harmony channel markers around its
|
|
490
|
+
// real XML output: `<|channel>thought\n<channel|>…<set path=…/>`.
|
|
491
|
+
// These aren't tool calls (handled above), they're role/channel
|
|
492
|
+
// tokens. Strip any remaining `<|name>` / `<name|>` pseudo-tags
|
|
493
|
+
// before the XML parser sees them.
|
|
494
|
+
result = result.replace(/<\|[\w:/-]+>/g, "");
|
|
495
|
+
result = result.replace(/<[\w:/-]+\|>/g, "");
|
|
496
|
+
|
|
322
497
|
return result;
|
|
323
498
|
}
|
|
324
499
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { countTokens } from "./tokens.js";
|
|
2
|
+
|
|
3
|
+
const CEILING_RATIO = Number(process.env.RUMMY_BUDGET_CEILING);
|
|
4
|
+
if (!CEILING_RATIO) throw new Error("RUMMY_BUDGET_CEILING must be set");
|
|
5
|
+
|
|
6
|
+
export function ceiling(contextSize) {
|
|
7
|
+
return Math.floor(contextSize * CEILING_RATIO);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sum assembled-message token counts.
|
|
12
|
+
* Used by the budget enforce gate, which has the real messages.
|
|
13
|
+
*/
|
|
14
|
+
export function measureMessages(messages) {
|
|
15
|
+
return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sum projected row body token counts — what's actually in the packet
|
|
20
|
+
* for each entry at its current visibility. Used by prompt.js while
|
|
21
|
+
* generating the <prompt> tag (before assembly completes).
|
|
22
|
+
*/
|
|
23
|
+
export function measureRows(rows) {
|
|
24
|
+
return rows.reduce((sum, r) => sum + countTokens(r.body), 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Single source of truth for budget numbers. Every caller — prompt.js
|
|
29
|
+
* generating the <prompt> tag, budget.js enforcing the ceiling,
|
|
30
|
+
* AgentLoop emitting telemetry — passes in its own measured totalTokens
|
|
31
|
+
* and reads the same object back. No fallbacks: callers produce the
|
|
32
|
+
* measurement they have.
|
|
33
|
+
*
|
|
34
|
+
* Returns:
|
|
35
|
+
* ceiling — floor(contextSize × CEILING_RATIO), the hard wall
|
|
36
|
+
* totalTokens — echoed back (the full packet size the caller measured)
|
|
37
|
+
* tokenUsage — same as totalTokens. Kept under this name for the
|
|
38
|
+
* `<prompt tokenUsage="N">` attribute on the wire. Must
|
|
39
|
+
* agree with totalTokens so the model's math is honest.
|
|
40
|
+
* tokensFree — ceiling − totalTokens (floor 0)
|
|
41
|
+
* overflow — max(0, totalTokens − ceiling)
|
|
42
|
+
* ok — overflow === 0
|
|
43
|
+
*/
|
|
44
|
+
export function computeBudget({ contextSize, totalTokens }) {
|
|
45
|
+
const cap = ceiling(contextSize);
|
|
46
|
+
const tokensFree = Math.max(0, cap - totalTokens);
|
|
47
|
+
const overflow = Math.max(0, totalTokens - cap);
|
|
48
|
+
return {
|
|
49
|
+
ceiling: cap,
|
|
50
|
+
totalTokens,
|
|
51
|
+
tokenUsage: totalTokens,
|
|
52
|
+
tokensFree,
|
|
53
|
+
overflow,
|
|
54
|
+
ok: overflow === 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors for the agent/Entries layer. Callers catch by type,
|
|
3
|
+
* not by regex.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when a writer tier isn't permitted to write to a scheme.
|
|
8
|
+
* See SPEC writer_tiers: schemes declare writable_by = subset of
|
|
9
|
+
* {system, plugin, client, model}. A write from an excluded tier
|
|
10
|
+
* rejects with this error.
|
|
11
|
+
*/
|
|
12
|
+
export class PermissionError extends Error {
|
|
13
|
+
constructor(scheme, writer, allowed) {
|
|
14
|
+
super(
|
|
15
|
+
`403: writer "${writer}" not permitted for scheme "${scheme ?? "file"}" (allowed: ${allowed.join(", ")})`,
|
|
16
|
+
);
|
|
17
|
+
this.name = "PermissionError";
|
|
18
|
+
this.scheme = scheme ?? "file";
|
|
19
|
+
this.writer = writer;
|
|
20
|
+
this.allowed = [...allowed];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map the entry-layer (state, outcome) tuple to an HTTP status number for
|
|
3
|
+
* model-facing tag rendering.
|
|
4
|
+
*
|
|
5
|
+
* Model-facing tags still carry `status="NNN"` because the model's
|
|
6
|
+
* vocabulary (instructions + tooldocs + training) is HTTP-shaped. The DB
|
|
7
|
+
* stores categorical state + textual outcome (see SPEC entries); this helper
|
|
8
|
+
* is the one-way translation for rendering.
|
|
9
|
+
*
|
|
10
|
+
* Outcome strings prefixed with a 3-digit HTTP code (e.g.
|
|
11
|
+
* `"overflow:413:..."` or `"permission:403:..."`) extract the code
|
|
12
|
+
* verbatim. Otherwise state maps to a canonical HTTP:
|
|
13
|
+
*
|
|
14
|
+
* resolved → 200
|
|
15
|
+
* proposed → 202
|
|
16
|
+
* streaming → 102
|
|
17
|
+
* cancelled → 499
|
|
18
|
+
* failed → 500 (unless outcome carries a code)
|
|
19
|
+
*/
|
|
20
|
+
export function stateToStatus(state, outcome = null) {
|
|
21
|
+
if (outcome) {
|
|
22
|
+
const match = /(\d{3})/.exec(outcome);
|
|
23
|
+
if (match) return Number(match[1]);
|
|
24
|
+
}
|
|
25
|
+
switch (state) {
|
|
26
|
+
case "resolved":
|
|
27
|
+
return 200;
|
|
28
|
+
case "proposed":
|
|
29
|
+
return 202;
|
|
30
|
+
case "streaming":
|
|
31
|
+
return 102;
|
|
32
|
+
case "cancelled":
|
|
33
|
+
return 499;
|
|
34
|
+
case "failed":
|
|
35
|
+
return 500;
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`stateToStatus: unknown state "${state}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -17,27 +17,31 @@ SELECT path, body, attributes, turn
|
|
|
17
17
|
FROM known_entries
|
|
18
18
|
WHERE
|
|
19
19
|
run_id = :run_id
|
|
20
|
-
AND
|
|
20
|
+
AND state = 'proposed';
|
|
21
21
|
|
|
22
22
|
-- PREP: has_rejections
|
|
23
|
+
-- Any failed entry in this loop counts as a rejection. Callers use
|
|
24
|
+
-- this to mark the turn as having errors. Specific failure categories
|
|
25
|
+
-- live in run_views.outcome (permission:, overflow:, validation:, ...).
|
|
23
26
|
SELECT COUNT(*) AS count
|
|
24
27
|
FROM known_entries
|
|
25
28
|
WHERE
|
|
26
29
|
run_id = :run_id
|
|
27
30
|
AND loop_id = :loop_id
|
|
28
|
-
AND
|
|
31
|
+
AND state = 'failed';
|
|
29
32
|
|
|
30
33
|
-- PREP: has_accepted_actions
|
|
31
34
|
SELECT COUNT(*) AS count
|
|
32
35
|
FROM known_entries
|
|
33
36
|
WHERE
|
|
34
37
|
run_id = :run_id
|
|
35
|
-
AND
|
|
38
|
+
AND state = 'resolved'
|
|
36
39
|
AND scheme IN ('set', 'sh', 'rm', 'mv', 'cp');
|
|
37
40
|
|
|
38
41
|
-- PREP: get_file_entries
|
|
39
|
-
SELECT path,
|
|
42
|
+
SELECT path, state, outcome, visibility, hash, updated_at
|
|
40
43
|
FROM known_entries
|
|
41
44
|
WHERE
|
|
42
45
|
run_id = :run_id
|
|
43
46
|
AND scheme IS NULL;
|
|
47
|
+
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
-- PREP: get_known_entries
|
|
2
|
-
SELECT
|
|
2
|
+
SELECT
|
|
3
|
+
path, scheme, state, outcome, visibility, body, turn, hash
|
|
4
|
+
, attributes, countTokens(body) AS tokens, scope
|
|
3
5
|
FROM known_entries
|
|
4
6
|
WHERE run_id = :run_id
|
|
5
7
|
ORDER BY path;
|
|
6
8
|
|
|
7
9
|
-- PREP: get_results
|
|
8
|
-
SELECT tool,
|
|
10
|
+
SELECT tool, state, outcome, path, body, turn, attributes
|
|
9
11
|
FROM v_run_log
|
|
10
12
|
WHERE run_id = :run_id;
|
|
11
13
|
|
|
@@ -18,7 +20,7 @@ WHERE
|
|
|
18
20
|
ORDER BY id;
|
|
19
21
|
|
|
20
22
|
-- PREP: get_turn_audit
|
|
21
|
-
SELECT path, scheme,
|
|
23
|
+
SELECT path, scheme, state, outcome, visibility, turn, body, attributes
|
|
22
24
|
FROM known_entries
|
|
23
25
|
WHERE
|
|
24
26
|
run_id = :run_id
|
|
@@ -53,24 +55,18 @@ ORDER BY id DESC
|
|
|
53
55
|
LIMIT 1;
|
|
54
56
|
|
|
55
57
|
-- PREP: get_latest_summary
|
|
58
|
+
-- Updates live in the unified log namespace at log://turn_N/update/<slug>,
|
|
59
|
+
-- not at a dedicated "update" scheme. Match path shape instead of scheme.
|
|
56
60
|
SELECT body
|
|
57
61
|
FROM known_entries
|
|
58
62
|
WHERE
|
|
59
63
|
run_id = :run_id
|
|
60
64
|
AND loop_id = :loop_id
|
|
61
|
-
AND
|
|
65
|
+
AND path LIKE 'log://turn_%/update/%'
|
|
62
66
|
ORDER BY id DESC
|
|
63
67
|
LIMIT 1;
|
|
64
68
|
|
|
65
|
-
--
|
|
66
|
-
SELECT ke.path, ke.status, ke.body, ke.attributes, ke.turn
|
|
67
|
-
FROM known_entries AS ke
|
|
68
|
-
JOIN schemes AS s ON s.name = COALESCE(ke.scheme, 'file')
|
|
69
|
-
WHERE
|
|
70
|
-
ke.run_id = :run_id
|
|
71
|
-
AND ke.scheme IS NOT NULL
|
|
72
|
-
AND s.category NOT IN ('knowledge', 'file', 'audit')
|
|
73
|
-
ORDER BY ke.id;
|
|
69
|
+
-- get_history retired — use get_results (v_run_log) for both run/state and getRun.
|
|
74
70
|
|
|
75
71
|
-- PREP: get_content
|
|
76
72
|
SELECT path, body, turn
|