@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.
Files changed (153) hide show
  1. package/.env.example +21 -4
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -118
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +47 -8
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -77
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +52 -8
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -17
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -27
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -37
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -28
@@ -1,18 +1,15 @@
1
1
  import { Parser } from "htmlparser2";
2
2
  import { parseEditContent } from "../plugins/hedberg/edits.js";
3
- import { normalizeAttrs, parseJsonEdit } from "../plugins/hedberg/normalize.js";
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, attrs, rawBody) {
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 fidelity change
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 === "summarize" || name === "update" || name === "unknown") {
92
+ if (name === "update") {
97
93
  const body = trimmed || a.body || "";
98
- return { name, body };
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
- const path = a.path || trimmed || null;
109
- return { name, path, body: a.body, preview: a.preview };
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
- const to = a.to || trimmed || null;
120
- return { name, path: a.path, to };
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) || 99;
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
- if (!ALL_TOOLS.has(name)) {
166
- if (current) {
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
- ? `<${name} ${attrStr}>`
172
- : `<${name}>`;
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
- // Known tool opened while another is still open — close the old one.
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
- if (current && name === current.name) {
208
- if (ended) {
209
- warnings.push(`Unclosed <${name}> tagcontent captured anyway`);
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
- commands.push(
212
- resolveCommand(current.name, current.attrs, current.rawBody),
213
- );
214
- current = null;
215
- } else if (current && ALL_TOOLS.has(name)) {
216
- // Mismatched close tag for a known tool close current tag,
217
- // don't swallow subsequent commands as body text.
218
- warnings.push(
219
- `Mismatched </${name}> closing <${current.name}> — recovered`,
220
- );
221
- commands.push(
222
- resolveCommand(current.name, current.attrs, current.rawBody),
223
- );
224
- current = null;
225
- } else if (current) {
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
- } else if (isImplied && ALL_TOOLS.has(name)) {
228
- // Self-closing tag that htmlparser2 auto-closed
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(normalized);
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/>` → `&lt;get/&gt;` (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, "&lt;").replace(/>/g, "&gt;")}\``;
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
- (_, name, params) => {
282
- if (!ALL_TOOLS.has(name)) return _;
283
- const valueMatch = params.match(/["']([^"']+)["']/);
284
- const body = valueMatch?.[1] || "";
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 status = 202;
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 status = 403;
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 status = 200
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, status, fidelity, hash, updated_at
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 path, scheme, status, fidelity, body, turn, hash, attributes, tokens
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, target, status, path, body
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, status, fidelity, turn, body, attributes
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 scheme = 'summarize'
65
+ AND path LIKE 'log://turn_%/update/%'
62
66
  ORDER BY id DESC
63
67
  LIMIT 1;
64
68
 
65
- -- PREP: get_history
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