@possumtech/rummy 0.3.1 → 0.5.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 (63) hide show
  1. package/.env.example +12 -0
  2. package/FIDELITY_CONTRACT.md +172 -0
  3. package/README.md +5 -1
  4. package/SPEC.md +31 -17
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +1 -1
  7. package/src/agent/AgentLoop.js +51 -153
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +16 -9
  10. package/src/agent/ResponseHealer.js +54 -1
  11. package/src/agent/TurnExecutor.js +125 -323
  12. package/src/agent/XmlParser.js +172 -42
  13. package/src/agent/known_queries.sql +1 -1
  14. package/src/agent/known_store.sql +29 -72
  15. package/src/agent/runs.sql +2 -2
  16. package/src/hooks/Hooks.js +1 -0
  17. package/src/hooks/PluginContext.js +8 -2
  18. package/src/hooks/RummyContext.js +6 -3
  19. package/src/hooks/ToolRegistry.js +29 -32
  20. package/src/plugins/ask_user/ask_user.js +2 -2
  21. package/src/plugins/ask_user/ask_userDoc.js +7 -10
  22. package/src/plugins/budget/README.md +28 -18
  23. package/src/plugins/budget/budget.js +80 -3
  24. package/src/plugins/budget/recovery.js +47 -0
  25. package/src/plugins/cp/cp.js +5 -5
  26. package/src/plugins/cp/cpDoc.js +1 -14
  27. package/src/plugins/engine/engine.sql +1 -1
  28. package/src/plugins/env/env.js +4 -4
  29. package/src/plugins/env/envDoc.js +4 -9
  30. package/src/plugins/file/file.js +2 -7
  31. package/src/plugins/get/get.js +32 -13
  32. package/src/plugins/get/getDoc.js +26 -44
  33. package/src/plugins/helpers.js +4 -4
  34. package/src/plugins/instructions/instructions.js +9 -7
  35. package/src/plugins/instructions/preamble.md +45 -26
  36. package/src/plugins/known/known.js +71 -15
  37. package/src/plugins/known/knownDoc.js +4 -20
  38. package/src/plugins/mv/mv.js +6 -6
  39. package/src/plugins/mv/mvDoc.js +4 -30
  40. package/src/plugins/policy/policy.js +47 -0
  41. package/src/plugins/previous/previous.js +10 -14
  42. package/src/plugins/progress/progress.js +29 -48
  43. package/src/plugins/prompt/prompt.js +18 -6
  44. package/src/plugins/rm/rm.js +4 -4
  45. package/src/plugins/rm/rmDoc.js +5 -14
  46. package/src/plugins/rpc/rpc.js +4 -2
  47. package/src/plugins/set/set.js +86 -91
  48. package/src/plugins/set/setDoc.js +28 -41
  49. package/src/plugins/sh/sh.js +4 -4
  50. package/src/plugins/sh/shDoc.js +4 -9
  51. package/src/plugins/skill/skill.js +2 -1
  52. package/src/plugins/summarize/summarize.js +9 -2
  53. package/src/plugins/summarize/summarizeDoc.js +10 -16
  54. package/src/plugins/telemetry/telemetry.js +36 -11
  55. package/src/plugins/think/think.js +13 -0
  56. package/src/plugins/think/thinkDoc.js +16 -0
  57. package/src/plugins/unknown/unknown.js +37 -9
  58. package/src/plugins/unknown/unknownDoc.js +7 -16
  59. package/src/plugins/update/update.js +9 -2
  60. package/src/plugins/update/updateDoc.js +12 -14
  61. package/src/server/ClientConnection.js +11 -1
  62. package/src/sql/functions/slugify.js +13 -1
  63. package/src/sql/v_model_context.sql +6 -6
@@ -14,8 +14,6 @@ export const ALL_TOOLS = new Set([
14
14
  "update",
15
15
  "unknown",
16
16
  "think",
17
- "thought",
18
- "mcp",
19
17
  ]);
20
18
 
21
19
  /**
@@ -145,6 +143,8 @@ export default class XmlParser {
145
143
  * @param {string} content - Raw model response text
146
144
  * @returns {{ commands: Array, warnings: string[], unparsed: string }}
147
145
  */
146
+ static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
147
+
148
148
  static parse(content) {
149
149
  if (!content) return { commands: [], warnings: [], unparsed: "" };
150
150
 
@@ -154,33 +154,67 @@ export default class XmlParser {
154
154
  const commands = [];
155
155
  const warnings = [];
156
156
  const textChunks = [];
157
+
158
+ // Pre-flight: balance unclosed attribute quotes that would otherwise
159
+ // cause htmlparser2 to consume the rest of input as a single attribute
160
+ // value, silently dropping every subsequent tool call.
161
+ const balanced = XmlParser.#balanceAttrQuotes(normalized, warnings);
157
162
  let current = null;
158
163
  let ended = false;
164
+ let capped = false;
159
165
 
160
166
  const parser = new Parser(
161
167
  {
162
168
  onopentag(name, attrs) {
163
- if (!ALL_TOOLS.has(name)) {
164
- if (current) {
165
- current.rawBody += `<${name}>`;
169
+ if (capped) return;
170
+
171
+ if (current) {
172
+ // Empty-body case: current tool opened but got no text
173
+ // content before a new tag. The model likely meant current
174
+ // to self-close but typed it in paired form, or emitted a
175
+ // mismatched close tag that htmlparser2 silently dropped.
176
+ // Close current, open new.
177
+ const hasBody = current.rawBody.trim() !== "";
178
+ const hasNestedOpens = (current.nested || []).length > 0;
179
+ if (!hasBody && !hasNestedOpens && ALL_TOOLS.has(name)) {
180
+ warnings.push(
181
+ `Unclosed <${current.name}> before <${name}> — recovered`,
182
+ );
183
+ commands.push(
184
+ resolveCommand(current.name, current.attrs, current.rawBody),
185
+ );
186
+ current = null;
187
+ } else {
188
+ // Nested tag inside a body with content — treat as body
189
+ // text. Tool bodies are opaque: the model writing a plan
190
+ // with <get/> in it, SEARCH/REPLACE in <set>, or XML
191
+ // examples in <known> all need to survive intact. Track
192
+ // nested opens on a stack so matching closes pop off and
193
+ // orphan closes (typos) still trigger recovery.
194
+ const attrStr = Object.entries(attrs)
195
+ .map(([k, v]) => (v === "" ? k : `${k}="${v}"`))
196
+ .join(" ");
197
+ current.rawBody += attrStr
198
+ ? `<${name} ${attrStr}>`
199
+ : `<${name}>`;
200
+ current.nested ||= [];
201
+ current.nested.push(name);
202
+ return;
166
203
  }
167
- return;
168
204
  }
169
205
 
170
- // Known tool opened while another is still open — close the old one.
171
- if (current) {
172
- warnings.push(
173
- `Unclosed <${current.name}> before <${name}> — recovered`,
174
- );
175
- commands.push(
176
- resolveCommand(current.name, current.attrs, current.rawBody),
177
- );
206
+ if (!ALL_TOOLS.has(name)) return;
207
+
208
+ if (commands.length >= XmlParser.MAX_COMMANDS) {
209
+ capped = true;
210
+ return;
178
211
  }
179
212
 
180
- current = { name, attrs, rawBody: "" };
213
+ current = { name, attrs, rawBody: "", nested: [] };
181
214
  },
182
215
 
183
216
  ontext(text) {
217
+ if (capped) return;
184
218
  if (current) {
185
219
  current.rawBody += text;
186
220
  } else {
@@ -189,28 +223,53 @@ export default class XmlParser {
189
223
  },
190
224
 
191
225
  onclosetag(name, isImplied) {
192
- if (current && name === current.name) {
193
- if (ended) {
194
- warnings.push(`Unclosed <${name}> tag — content captured anyway`);
226
+ if (capped) return;
227
+
228
+ if (current) {
229
+ // Matching nested close — pop stack, keep as text.
230
+ const nested = current.nested;
231
+ if (
232
+ nested.length > 0 &&
233
+ nested[nested.length - 1] === name
234
+ ) {
235
+ nested.pop();
236
+ current.rawBody += `</${name}>`;
237
+ return;
238
+ }
239
+
240
+ // Matching close for outer tool — finalize.
241
+ if (name === current.name && nested.length === 0) {
242
+ if (ended) {
243
+ warnings.push(
244
+ `Unclosed <${name}> tag — content captured anyway`,
245
+ );
246
+ }
247
+ commands.push(
248
+ resolveCommand(current.name, current.attrs, current.rawBody),
249
+ );
250
+ current = null;
251
+ return;
252
+ }
253
+
254
+ // Orphan close for a known tool (likely typo) — recover.
255
+ if (ALL_TOOLS.has(name)) {
256
+ warnings.push(
257
+ `Mismatched </${name}> closing <${current.name}> — recovered`,
258
+ );
259
+ commands.push(
260
+ resolveCommand(current.name, current.attrs, current.rawBody),
261
+ );
262
+ current = null;
263
+ return;
195
264
  }
196
- commands.push(
197
- resolveCommand(current.name, current.attrs, current.rawBody),
198
- );
199
- current = null;
200
- } else if (current && ALL_TOOLS.has(name)) {
201
- // Mismatched close tag for a known tool — close current tag,
202
- // don't swallow subsequent commands as body text.
203
- warnings.push(
204
- `Mismatched </${name}> closing <${current.name}> — recovered`,
205
- );
206
- commands.push(
207
- resolveCommand(current.name, current.attrs, current.rawBody),
208
- );
209
- current = null;
210
- } else if (current) {
265
+
266
+ // Unknown orphan close — text.
211
267
  current.rawBody += `</${name}>`;
212
- } else if (isImplied && ALL_TOOLS.has(name)) {
213
- // Self-closing tag that htmlparser2 auto-closed
268
+ return;
269
+ }
270
+
271
+ if (isImplied && ALL_TOOLS.has(name)) {
272
+ // Self-closing tag that htmlparser2 auto-closed at top level
214
273
  }
215
274
  },
216
275
 
@@ -225,12 +284,12 @@ export default class XmlParser {
225
284
  },
226
285
  );
227
286
 
228
- parser.write(normalized);
287
+ parser.write(balanced);
229
288
  ended = true;
230
289
  parser.end();
231
290
 
232
291
  // Flush any unclosed tool tag
233
- if (current) {
292
+ if (current && !capped) {
234
293
  warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
235
294
  commands.push(
236
295
  resolveCommand(current.name, current.attrs, current.rawBody),
@@ -238,10 +297,46 @@ export default class XmlParser {
238
297
  current = null;
239
298
  }
240
299
 
300
+ if (capped) {
301
+ warnings.push(
302
+ `Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
303
+ );
304
+ }
305
+
241
306
  const unparsed = textChunks.join("").trim();
242
307
  return { commands, warnings, unparsed };
243
308
  }
244
309
 
310
+ /**
311
+ * Repair a specific malformed-tag pattern: an attribute value opened with
312
+ * `="` that never closes before the next tag. Without repair, htmlparser2
313
+ * consumes the rest of input as one giant attribute value and silently
314
+ * drops every subsequent tool call.
315
+ *
316
+ * Pattern matched: <TAG ... ATTR="text-with-no-quote</NEXT>
317
+ * Repair: <TAG ... ATTR="text-with-no-quote"></NEXT>
318
+ *
319
+ * Conservative — only triggers when the value contains no quote, no `>`,
320
+ * and is followed by another tag opening or close. Well-formed input is
321
+ * untouched.
322
+ */
323
+ static #balanceAttrQuotes(content, warnings) {
324
+ let fixes = 0;
325
+ const repaired = content.replace(
326
+ /(<\w+\s[^<>]*?\w+=")([^"<>]*?)(<\/?\w+)/g,
327
+ (_, opening, value, nextTag) => {
328
+ fixes++;
329
+ return `${opening}${value}">${nextTag}`;
330
+ },
331
+ );
332
+ if (fixes > 0) {
333
+ warnings.push(
334
+ `Repaired ${fixes} malformed attribute(s) — close all attribute values with a quote.`,
335
+ );
336
+ }
337
+ return repaired;
338
+ }
339
+
245
340
  /**
246
341
  * Normalize native tool call formats to rummy XML.
247
342
  * Models sometimes emit their training-format tool calls instead of
@@ -255,12 +350,30 @@ export default class XmlParser {
255
350
  );
256
351
 
257
352
  // Qwen/gemma: <|tool_call>call:NAME{key:"value"}<tool_call|>
353
+ // NAME may be namespaced with any of /, :, or . separators
354
+ // (e.g. `rummy.nvim/get`, `rummy:get`) — extract the trailing word
355
+ // sequence as the tool name. Value forms observed in the wild:
356
+ // key="v" / key:"v" / key:v (unquoted) / key:<|"|>v<|"|> (gemma chat-quotes)
258
357
  result = result.replace(
259
- /<\|tool_call>call:(\w+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
260
- (_, name, params) => {
261
- if (!ALL_TOOLS.has(name)) return _;
262
- const valueMatch = params.match(/["']([^"']+)["']/);
263
- const body = valueMatch?.[1] || "";
358
+ /<\|tool_call>call:([\w.:/-]+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
359
+ (match, qualifiedName, params) => {
360
+ const name = qualifiedName.match(/\w+$/)?.[0] ?? qualifiedName;
361
+ if (!ALL_TOOLS.has(name)) {
362
+ return `<error>Unknown tool '${qualifiedName}' in <|tool_call> format. Use XML tool commands listed above.</error>`;
363
+ }
364
+ const valueMatch = params.match(
365
+ /[=:]\s*(?:<\|"\|>([^<]*?)<\|"\|>|"([^"]*)"|'([^']*)'|([^,}]+))/,
366
+ );
367
+ const body = (
368
+ valueMatch?.[1] ??
369
+ valueMatch?.[2] ??
370
+ valueMatch?.[3] ??
371
+ valueMatch?.[4] ??
372
+ ""
373
+ ).trim();
374
+ if (!body) {
375
+ return `<error>Could not extract argument from <|tool_call> ${match}. Use XML format like <${name}>value</${name}>.</error>`;
376
+ }
264
377
  return `<${name}>${body}</${name}>`;
265
378
  },
266
379
  );
@@ -298,6 +411,23 @@ export default class XmlParser {
298
411
  },
299
412
  );
300
413
 
414
+ // Catch-all: any remaining <|tool_call> tokens are malformed native
415
+ // attempts (no {} block, missing close, wrong shape entirely). Replace
416
+ // each with an <error> so the model gets feedback on its next turn and
417
+ // learns to switch to XML. Lazy-match up to the next native close, the
418
+ // next XML close tag, or end of input — preserves any trailing valid XML.
419
+ // Error body must NOT contain literal <get>/<set>/etc. — those would
420
+ // re-enter the parser as phantom tool calls. Describe the format in
421
+ // prose instead and point at the tool docs above.
422
+ result = result.replace(
423
+ /<\|tool_call>[\s\S]*?(?:<\|?tool_call\|?>|<\/\w+>|$)/g,
424
+ () =>
425
+ "<error>Native tool call format not supported. Use the XML tool commands listed above (e.g. a get tag with a path attribute, or a set tag with path and body).</error>",
426
+ );
427
+
428
+ // Strip any orphan chat-format quote tokens left after replacement.
429
+ result = result.replace(/<\|"\|>/g, '"');
430
+
301
431
  return result;
302
432
  }
303
433
  }
@@ -1,5 +1,5 @@
1
1
  -- PREP: get_known_entries
2
- SELECT path, scheme, status, fidelity, body, turn, hash, attributes
2
+ SELECT path, scheme, status, fidelity, body, turn, hash, attributes, tokens
3
3
  FROM known_entries
4
4
  WHERE run_id = :run_id
5
5
  ORDER BY path;
@@ -1,13 +1,12 @@
1
1
  -- PREP: upsert_known_entry
2
2
  INSERT INTO known_entries (
3
3
  run_id, loop_id, turn, path, body, status, fidelity, hash
4
- , attributes, tokens, tokens_full, updated_at
4
+ , attributes, tokens, updated_at
5
5
  )
6
6
  VALUES (
7
7
  :run_id, :loop_id, :turn, :path, :body, :status, :fidelity, :hash
8
8
  , COALESCE(:attributes, '{}')
9
9
  , countTokens(:body)
10
- , countTokens(:body)
11
10
  , COALESCE(:updated_at, CURRENT_TIMESTAMP)
12
11
  )
13
12
  ON CONFLICT (run_id, path) DO UPDATE SET
@@ -19,13 +18,12 @@ ON CONFLICT (run_id, path) DO UPDATE SET
19
18
  , loop_id = excluded.loop_id
20
19
  , turn = excluded.turn
21
20
  , tokens = countTokens(excluded.body)
22
- , tokens_full = countTokens(excluded.body)
23
21
  , write_count = known_entries.write_count + 1
24
22
  , updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP);
25
23
 
26
24
  -- PREP: recount_tokens
27
25
  UPDATE known_entries
28
- SET tokens = :tokens, tokens_full = :tokens
26
+ SET tokens = :tokens
29
27
  WHERE run_id = :run_id AND path = :path;
30
28
 
31
29
  -- PREP: get_stale_tokens
@@ -55,54 +53,30 @@ WHERE run_id = :run_id AND path = :path;
55
53
  UPDATE known_entries
56
54
  SET
57
55
  fidelity = :fidelity
58
- , tokens = CASE
59
- WHEN :fidelity = 'archive'
60
- THEN 0
61
- WHEN :fidelity = 'index'
62
- THEN 0
63
- WHEN :fidelity = 'summary'
64
- THEN COALESCE(
65
- countTokens(json_extract(attributes, '$.summary')),
66
- countTokens(substr(body, 1, 80))
67
- )
68
- ELSE tokens_full
69
- END
70
56
  , updated_at = CURRENT_TIMESTAMP
71
57
  WHERE run_id = :run_id AND hedmatch(:pattern, path) AND scheme IS NULL;
72
58
 
73
59
  -- PREP: promote_path
74
60
  UPDATE known_entries
75
61
  SET
76
- fidelity = 'full'
62
+ fidelity = 'promoted'
63
+ , status = 200
77
64
  , turn = :turn
78
- , tokens = tokens_full
79
65
  , updated_at = CURRENT_TIMESTAMP
80
66
  WHERE run_id = :run_id AND path = :path;
81
67
 
82
68
  -- PREP: demote_path
83
69
  UPDATE known_entries
84
70
  SET
85
- fidelity = 'archive'
86
- , tokens = 0
71
+ fidelity = 'archived'
87
72
  , updated_at = CURRENT_TIMESTAMP
88
73
  WHERE run_id = :run_id AND path = :path;
89
74
 
90
75
  -- PREP: set_fidelity
76
+ -- Tokens unchanged — always reflects full body cost.
91
77
  UPDATE known_entries
92
78
  SET
93
79
  fidelity = :fidelity
94
- , tokens = CASE
95
- WHEN :fidelity = 'archive'
96
- THEN 0
97
- WHEN :fidelity = 'index'
98
- THEN 0
99
- WHEN :fidelity = 'summary'
100
- THEN COALESCE(
101
- countTokens(json_extract(attributes, '$.summary')),
102
- countTokens(substr(body, 1, 80))
103
- )
104
- ELSE countTokens(body)
105
- END
106
80
  , updated_at = CURRENT_TIMESTAMP
107
81
  WHERE run_id = :run_id AND path = :path;
108
82
 
@@ -137,9 +111,9 @@ WHERE run_id = :run_id AND path = :path;
137
111
  -- PREP: promote_by_pattern
138
112
  UPDATE known_entries
139
113
  SET
140
- fidelity = 'full'
114
+ fidelity = 'promoted'
115
+ , status = 200
141
116
  , turn = :turn
142
- , tokens = tokens_full
143
117
  , updated_at = CURRENT_TIMESTAMP
144
118
  WHERE
145
119
  run_id = :run_id
@@ -149,8 +123,7 @@ WHERE
149
123
  -- PREP: demote_by_pattern
150
124
  UPDATE known_entries
151
125
  SET
152
- fidelity = 'archive'
153
- , tokens = 0
126
+ fidelity = 'archived'
154
127
  , updated_at = CURRENT_TIMESTAMP
155
128
  WHERE
156
129
  run_id = :run_id
@@ -158,7 +131,7 @@ WHERE
158
131
  AND (:body IS NULL OR hedsearch(:body, body));
159
132
 
160
133
  -- PREP: get_entries_by_pattern
161
- SELECT path, body, scheme, status, fidelity, tokens_full, attributes
134
+ SELECT path, body, scheme, status, fidelity, tokens, attributes
162
135
  FROM known_entries
163
136
  WHERE
164
137
  run_id = :run_id
@@ -182,7 +155,6 @@ UPDATE known_entries
182
155
  SET
183
156
  body = :new_body
184
157
  , tokens = countTokens(:new_body)
185
- , tokens_full = countTokens(:new_body)
186
158
  , write_count = write_count + 1
187
159
  , updated_at = CURRENT_TIMESTAMP
188
160
  WHERE
@@ -191,49 +163,34 @@ WHERE
191
163
  AND (:body IS NULL OR hedsearch(:body, body));
192
164
 
193
165
  -- PREP: restore_summarized_prompts
194
- -- Restore prompt entries demoted to summary by a recovery phase that was
166
+ -- Restore prompt entries demoted by a recovery phase that was
195
167
  -- interrupted (e.g. server crash). Safe to call unconditionally at loop
196
168
  -- start: if the full prompt would overflow, Prompt Demotion handles it.
197
169
  UPDATE known_entries
198
170
  SET
199
- fidelity = 'full'
200
- , tokens = tokens_full
201
- , updated_at = CURRENT_TIMESTAMP
202
- WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'summary';
203
-
204
- -- PREP: demote_previous_loop_logging
205
- -- Demote full logging entries from all other loops to summary.
206
- -- Fires at loop start so <previous> entries are already compact.
207
- UPDATE known_entries
208
- SET
209
- fidelity = 'summary'
210
- , tokens = COALESCE(
211
- countTokens(json_extract(attributes, '$.summary'))
212
- , countTokens(substr(body, 1, 80))
213
- )
171
+ fidelity = 'promoted'
214
172
  , updated_at = CURRENT_TIMESTAMP
215
- WHERE
216
- run_id = :run_id
217
- AND (loop_id IS NULL OR loop_id != :loop_id)
218
- AND fidelity = 'full'
219
- AND scheme IN (SELECT name FROM schemes WHERE category = 'logging');
220
-
221
- -- PREP: demote_turn_data_entries
222
- -- Demote full data entries from a turn to summary with 413 status.
223
- -- Fires when end-of-turn materialization exceeds the context ceiling.
173
+ WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'demoted';
174
+
175
+ -- PREP: demote_turn_entries
176
+ -- Demote all promoted entries from a turn for budget claw-back.
177
+ -- Action schemes (set/rm/mv/cp) at status 200 keep their status — those
178
+ -- represent committed side effects (files written/removed) that can't be
179
+ -- clawed back; only the body in context is demoted, not the truth of what
180
+ -- happened. Everything else flips to 413 since promotion was reversed.
181
+ -- Tokens unchanged always reports full cost regardless of fidelity.
224
182
  UPDATE known_entries
225
183
  SET
226
- fidelity = 'summary'
227
- , status = 413
228
- , tokens = COALESCE(
229
- countTokens(json_extract(attributes, '$.summary'))
230
- , countTokens(substr(body, 1, 80))
231
- )
184
+ fidelity = 'demoted'
185
+ , status = CASE
186
+ WHEN scheme IN ('set', 'rm', 'mv', 'cp') AND status = 200 THEN 200
187
+ ELSE 413
188
+ END
232
189
  , updated_at = CURRENT_TIMESTAMP
233
190
  WHERE
234
191
  run_id = :run_id
235
192
  AND turn = :turn
236
- AND fidelity = 'full'
193
+ AND fidelity = 'promoted'
237
194
  AND status < 400
238
- AND scheme IN (SELECT name FROM schemes WHERE category = 'data')
239
- RETURNING path;
195
+ RETURNING path, tokens;
196
+
@@ -81,11 +81,11 @@ RETURNING next_turn - 1 AS turn;
81
81
  -- PREP: fork_known_entries
82
82
  INSERT INTO known_entries (
83
83
  run_id, loop_id, turn, path, body, status, fidelity
84
- , hash, attributes, tokens, tokens_full, refs, write_count
84
+ , hash, attributes, tokens, refs, write_count
85
85
  )
86
86
  SELECT
87
87
  :new_run_id, NULL, turn, path, body, status, fidelity
88
- , hash, attributes, tokens, tokens_full, refs, write_count
88
+ , hash, attributes, tokens, refs, write_count
89
89
  FROM known_entries
90
90
  WHERE run_id = :parent_run_id;
91
91
 
@@ -56,6 +56,7 @@ export default function createHooks(debug = false) {
56
56
  turn: {
57
57
  started: createEvent("turn.started"),
58
58
  response: createEvent("turn.response"),
59
+ proposal: createEvent("turn.proposal"),
59
60
  proposing: createEvent("turn.proposing"),
60
61
  completed: createEvent("turn.completed"),
61
62
  },
@@ -69,10 +69,16 @@ export default class PluginContext {
69
69
  this.#hooks.tools.ensureTool(this.#name);
70
70
  }
71
71
 
72
+ // Mark this plugin's tool as hidden from model-facing tool lists.
73
+ // Handler still dispatches if the model emits the tag.
74
+ markHidden() {
75
+ this.#hooks.tools.markHidden(this.#name);
76
+ }
77
+
72
78
  /**
73
79
  * Register a named callback for this plugin.
74
80
  * "handler" registers the tool handler.
75
- * "full"/"summary" register fidelity projections.
81
+ * "promoted"/"demoted" register fidelity projections.
76
82
  * "docs" sets tool documentation.
77
83
  * Everything else resolves to a hook event.
78
84
  */
@@ -82,7 +88,7 @@ export default class PluginContext {
82
88
  this.#hooks.tools.onHandle(this.#name, callback, priority);
83
89
  return;
84
90
  }
85
- if (event === "full" || event === "summary") {
91
+ if (event === "promoted" || event === "demoted") {
86
92
  this.#hooks.tools.onView(this.#name, callback, event);
87
93
  return;
88
94
  }
@@ -103,9 +103,12 @@ export default class RummyContext {
103
103
 
104
104
  async set({ path, body, status = 200, fidelity, attributes } = {}) {
105
105
  if (!path) {
106
- const slugify = (await import("../sql/functions/slugify.js")).default;
107
- const base = slugify(body || "");
108
- path = `known://${base || Date.now()}`;
106
+ path = await this.entries.slugPath(
107
+ this.runId,
108
+ "known",
109
+ body || "",
110
+ attributes?.summary,
111
+ );
109
112
  }
110
113
  await this.entries.upsert(
111
114
  this.runId,
@@ -1,19 +1,20 @@
1
1
  // Tool display order: gather → reason → act → communicate.
2
2
  // Position in the list implies priority to the model.
3
3
  const TOOL_ORDER = [
4
+ "think",
5
+ "unknown",
6
+ "known",
4
7
  "get",
5
8
  "set",
6
- "known",
7
- "unknown",
8
9
  "env",
9
10
  "sh",
10
11
  "rm",
11
12
  "cp",
12
13
  "mv",
13
- "search",
14
- "summarize",
15
- "update",
16
14
  "ask_user",
15
+ "update",
16
+ "summarize",
17
+ "search",
17
18
  ];
18
19
 
19
20
  function sortByPriority(names) {
@@ -31,12 +32,21 @@ export default class ToolRegistry {
31
32
  #tools = new Map();
32
33
  #handlers = new Map();
33
34
  #views = new Map();
35
+ #hidden = new Set();
34
36
 
35
37
  ensureTool(scheme) {
36
38
  if (this.#tools.has(scheme)) return;
37
39
  this.#tools.set(scheme, Object.freeze({}));
38
40
  }
39
41
 
42
+ // Mark a tool as hidden — handler still dispatches if the model emits the
43
+ // tag, but the tool is excluded from all model-facing tool lists. Used for
44
+ // legacy/internal schemes (e.g. <known>, <unknown>) we want to retire
45
+ // without deleting.
46
+ markHidden(scheme) {
47
+ this.#hidden.add(scheme);
48
+ }
49
+
40
50
  get(name) {
41
51
  return this.#tools.get(name);
42
52
  }
@@ -52,7 +62,7 @@ export default class ToolRegistry {
52
62
  list.sort((a, b) => a.priority - b.priority);
53
63
  }
54
64
 
55
- onView(scheme, fn, fidelity = "full") {
65
+ onView(scheme, fn, fidelity = "promoted") {
56
66
  if (!this.#views.has(scheme)) this.#views.set(scheme, new Map());
57
67
  this.#views.get(scheme).set(fidelity, fn);
58
68
  }
@@ -66,32 +76,11 @@ export default class ToolRegistry {
66
76
  );
67
77
  }
68
78
 
69
- const attrs =
70
- typeof entry.attributes === "string"
71
- ? JSON.parse(entry.attributes)
72
- : entry.attributes;
73
- const summary = typeof attrs?.summary === "string" ? attrs.summary : null;
74
-
75
- const fidelity = entry.fidelity || "full";
79
+ const fidelity = entry.fidelity || "promoted";
76
80
  const fn = fidelityMap.get(fidelity);
77
- if (!fn) {
78
- // No view for this fidelity — fall back on model-authored summary
79
- return summary || "";
80
- }
81
-
82
- const body = await fn(entry);
83
-
84
- // Prepend summary keywords above plugin output at summary fidelity
85
- if (fidelity === "summary" && summary && body) {
86
- return `${summary}\n${body}`;
87
- }
81
+ if (!fn) return "";
88
82
 
89
- // Fall back to summary attribute when plugin returns empty
90
- if (fidelity === "summary" && summary && !body) {
91
- return summary;
92
- }
93
-
94
- return body;
83
+ return fn(entry);
95
84
  }
96
85
 
97
86
  hasView(scheme) {
@@ -112,15 +101,23 @@ export default class ToolRegistry {
112
101
  return sortByPriority([...this.#tools.keys()]);
113
102
  }
114
103
 
104
+ // Names advertised to the model — registered tools minus hidden ones.
105
+ // Use this anywhere a tool list is shown to the model.
106
+ get advertisedNames() {
107
+ return sortByPriority(
108
+ [...this.#tools.keys()].filter((n) => !this.#hidden.has(n)),
109
+ );
110
+ }
111
+
115
112
  /**
116
113
  * Compute the active tool set for a loop.
117
- * All exclusions — mode, flags — handled here. One mechanism.
114
+ * All exclusions — mode, flags, hidden — handled here. One mechanism.
118
115
  */
119
116
  resolveForLoop(
120
117
  mode,
121
118
  { noInteraction = false, noWeb = false, noProposals = false } = {},
122
119
  ) {
123
- const excluded = new Set();
120
+ const excluded = new Set(this.#hidden);
124
121
  if (mode === "ask") excluded.add("sh");
125
122
  if (noInteraction) excluded.add("ask_user");
126
123
  if (noWeb) excluded.add("search");