@possumtech/rummy 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +13 -2
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +440 -106
  5. package/migrations/001_initial_schema.sql +5 -3
  6. package/package.json +17 -5
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +252 -55
  9. package/src/agent/ContextAssembler.js +20 -4
  10. package/src/agent/KnownStore.js +82 -25
  11. package/src/agent/ProjectAgent.js +4 -1
  12. package/src/agent/ResponseHealer.js +86 -32
  13. package/src/agent/TurnExecutor.js +542 -207
  14. package/src/agent/XmlParser.js +77 -41
  15. package/src/agent/known_store.sql +68 -4
  16. package/src/agent/schemes.sql +3 -0
  17. package/src/agent/tokens.js +7 -21
  18. package/src/agent/turns.sql +15 -1
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +15 -0
  21. package/src/hooks/PluginContext.js +14 -1
  22. package/src/hooks/RummyContext.js +16 -4
  23. package/src/hooks/ToolRegistry.js +77 -19
  24. package/src/llm/LlmProvider.js +27 -8
  25. package/src/llm/OpenAiClient.js +20 -0
  26. package/src/llm/OpenRouterClient.js +24 -2
  27. package/src/llm/XaiClient.js +47 -2
  28. package/src/plugins/ask_user/README.md +4 -4
  29. package/src/plugins/ask_user/ask_user.js +5 -5
  30. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  31. package/src/plugins/budget/README.md +31 -0
  32. package/src/plugins/budget/budget.js +55 -0
  33. package/src/plugins/cp/README.md +5 -4
  34. package/src/plugins/cp/cp.js +10 -6
  35. package/src/plugins/cp/cpDoc.js +29 -0
  36. package/src/plugins/engine/engine.sql +1 -8
  37. package/src/plugins/engine/turn_context.sql +4 -9
  38. package/src/plugins/env/README.md +3 -4
  39. package/src/plugins/env/env.js +5 -5
  40. package/src/plugins/env/envDoc.js +29 -0
  41. package/src/plugins/file/README.md +9 -12
  42. package/src/plugins/file/file.js +34 -35
  43. package/src/plugins/get/README.md +2 -2
  44. package/src/plugins/get/get.js +77 -6
  45. package/src/plugins/get/getDoc.js +51 -0
  46. package/src/plugins/hedberg/hedberg.js +2 -1
  47. package/src/plugins/hedberg/matcher.js +10 -29
  48. package/src/plugins/hedberg/normalize.js +28 -0
  49. package/src/plugins/hedberg/patterns.js +25 -27
  50. package/src/plugins/hedberg/sed.js +17 -10
  51. package/src/plugins/index.js +66 -14
  52. package/src/plugins/instructions/README.md +6 -2
  53. package/src/plugins/instructions/instructions.js +20 -4
  54. package/src/plugins/instructions/preamble.md +19 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +23 -17
  57. package/src/plugins/known/knownDoc.js +34 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +27 -6
  60. package/src/plugins/mv/mvDoc.js +45 -0
  61. package/src/plugins/performed/README.md +15 -0
  62. package/src/plugins/performed/performed.js +45 -0
  63. package/src/plugins/persona/persona.js +78 -0
  64. package/src/plugins/previous/README.md +3 -2
  65. package/src/plugins/previous/previous.js +33 -24
  66. package/src/plugins/progress/README.md +1 -2
  67. package/src/plugins/progress/progress.js +33 -21
  68. package/src/plugins/prompt/README.md +5 -5
  69. package/src/plugins/prompt/prompt.js +15 -17
  70. package/src/plugins/rm/README.md +4 -4
  71. package/src/plugins/rm/rm.js +32 -20
  72. package/src/plugins/rm/rmDoc.js +30 -0
  73. package/src/plugins/rpc/README.md +15 -28
  74. package/src/plugins/rpc/rpc.js +42 -77
  75. package/src/plugins/set/README.md +13 -12
  76. package/src/plugins/set/set.js +107 -16
  77. package/src/plugins/set/setDoc.js +49 -0
  78. package/src/plugins/sh/README.md +4 -4
  79. package/src/plugins/sh/sh.js +5 -5
  80. package/src/plugins/sh/shDoc.js +29 -0
  81. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  82. package/src/plugins/summarize/README.md +6 -5
  83. package/src/plugins/summarize/summarize.js +7 -6
  84. package/src/plugins/summarize/summarizeDoc.js +33 -0
  85. package/src/plugins/telemetry/telemetry.js +16 -9
  86. package/src/plugins/think/README.md +20 -0
  87. package/src/plugins/think/think.js +5 -0
  88. package/src/plugins/unknown/README.md +6 -5
  89. package/src/plugins/unknown/unknown.js +12 -9
  90. package/src/plugins/unknown/unknownDoc.js +31 -0
  91. package/src/plugins/update/README.md +3 -8
  92. package/src/plugins/update/update.js +7 -6
  93. package/src/plugins/update/updateDoc.js +33 -0
  94. package/src/server/ClientConnection.js +59 -45
  95. package/src/server/RpcRegistry.js +52 -4
  96. package/src/sql/v_model_context.sql +10 -25
  97. package/src/plugins/ask_user/docs.md +0 -2
  98. package/src/plugins/cp/docs.md +0 -2
  99. package/src/plugins/current/README.md +0 -14
  100. package/src/plugins/current/current.js +0 -47
  101. package/src/plugins/env/docs.md +0 -4
  102. package/src/plugins/get/docs.md +0 -10
  103. package/src/plugins/known/docs.md +0 -3
  104. package/src/plugins/mv/docs.md +0 -2
  105. package/src/plugins/rm/docs.md +0 -6
  106. package/src/plugins/set/docs.md +0 -6
  107. package/src/plugins/sh/docs.md +0 -2
  108. package/src/plugins/skills/README.md +0 -25
  109. package/src/plugins/store/README.md +0 -20
  110. package/src/plugins/store/docs.md +0 -6
  111. package/src/plugins/store/store.js +0 -63
  112. package/src/plugins/summarize/docs.md +0 -4
  113. package/src/plugins/unknown/docs.md +0 -5
  114. package/src/plugins/update/docs.md +0 -4
@@ -1,18 +1,10 @@
1
1
  import { Parser } from "htmlparser2";
2
2
  import { parseEditContent } from "../plugins/hedberg/edits.js";
3
- import { normalizeAttrs } from "../plugins/hedberg/normalize.js";
3
+ import { normalizeAttrs, parseJsonEdit } from "../plugins/hedberg/normalize.js";
4
4
  import { parseSed } from "../plugins/hedberg/sed.js";
5
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([
6
+ const STORE_TOOLS = new Set(["get", "rm", "set", "mv", "cp", "search"]);
7
+ export const ALL_TOOLS = new Set([
16
8
  ...STORE_TOOLS,
17
9
  "known",
18
10
  "sh",
@@ -21,6 +13,9 @@ const ALL_TOOLS = new Set([
21
13
  "summarize",
22
14
  "update",
23
15
  "unknown",
16
+ "think",
17
+ "thought",
18
+ "mcp",
24
19
  ]);
25
20
 
26
21
  /**
@@ -51,31 +46,10 @@ function resolveCommand(name, attrs, rawBody) {
51
46
  };
52
47
  }
53
48
  }
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
- }
49
+ // JSON-style { search, replace }
50
+ const jsonEdit = parseJsonEdit(trimmed);
51
+ if (jsonEdit) {
52
+ return { name, path: a.path, ...jsonEdit };
79
53
  }
80
54
  // Sed syntax: s/search/replace/flags — supports chained commands
81
55
  if (trimmed.startsWith("s/")) {
@@ -116,9 +90,9 @@ function resolveCommand(name, attrs, rawBody) {
116
90
  preview: a.preview,
117
91
  };
118
92
  }
119
- // Plain write create/overwrite
93
+ // Plain write or fidelity change
120
94
  const body = trimmed || a.body || "";
121
- return { name, path: a.path, body, preview: a.preview };
95
+ return { name, ...a, body };
122
96
  }
123
97
 
124
98
  if (name === "summarize" || name === "update" || name === "unknown") {
@@ -129,10 +103,10 @@ function resolveCommand(name, attrs, rawBody) {
129
103
  if (name === "known") {
130
104
  const body = trimmed || a.body || "";
131
105
  const path = a.path || null;
132
- return { name, path, body };
106
+ return { name, ...a, path, body };
133
107
  }
134
108
 
135
- if (name === "get" || name === "store" || name === "rm") {
109
+ if (name === "get" || name === "rm") {
136
110
  const path = a.path || trimmed || null;
137
111
  return { name, path, body: a.body, preview: a.preview };
138
112
  }
@@ -174,6 +148,9 @@ export default class XmlParser {
174
148
  static parse(content) {
175
149
  if (!content) return { commands: [], warnings: [], unparsed: "" };
176
150
 
151
+ // Normalize native tool call formats to rummy XML
152
+ const normalized = XmlParser.#normalizeToolCalls(content);
153
+
177
154
  const commands = [];
178
155
  const warnings = [];
179
156
  const textChunks = [];
@@ -248,7 +225,7 @@ export default class XmlParser {
248
225
  },
249
226
  );
250
227
 
251
- parser.write(content);
228
+ parser.write(normalized);
252
229
  ended = true;
253
230
  parser.end();
254
231
 
@@ -264,4 +241,63 @@ export default class XmlParser {
264
241
  const unparsed = textChunks.join("").trim();
265
242
  return { commands, warnings, unparsed };
266
243
  }
244
+
245
+ /**
246
+ * Normalize native tool call formats to rummy XML.
247
+ * Models sometimes emit their training-format tool calls instead of
248
+ * our XML tags. The intent is unambiguous — translate silently.
249
+ */
250
+ static #normalizeToolCalls(content) {
251
+ // Gemma: ```tool_code\n<xml>...\n``` — strip code fences around valid XML
252
+ let result = content.replace(
253
+ /```(?:tool_code|tool_command|xml)\n([\s\S]*?)```/g,
254
+ (_, inner) => inner.trim(),
255
+ );
256
+
257
+ // Qwen/gemma: <|tool_call>call:NAME{key:"value"}<tool_call|>
258
+ 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] || "";
264
+ return `<${name}>${body}</${name}>`;
265
+ },
266
+ );
267
+
268
+ // OpenAI function_call JSON: {"name":"search","arguments":{"query":"..."}}
269
+ result = result.replace(
270
+ /\{"name"\s*:\s*"(\w+)"\s*,\s*"arguments"\s*:\s*\{([^}]*)\}\}/g,
271
+ (_, name, args) => {
272
+ if (!ALL_TOOLS.has(name)) return _;
273
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
274
+ const body = pairs[0]?.[2] || "";
275
+ return `<${name}>${body}</${name}>`;
276
+ },
277
+ );
278
+
279
+ // Anthropic: <tool_use><name>search</name><input>{"query":"..."}</input></tool_use>
280
+ result = result.replace(
281
+ /<tool_use>\s*<name>(\w+)<\/name>\s*<input>\{([^}]*)\}<\/input>\s*<\/tool_use>/g,
282
+ (_, name, args) => {
283
+ if (!ALL_TOOLS.has(name)) return _;
284
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
285
+ const body = pairs[0]?.[2] || "";
286
+ return `<${name}>${body}</${name}>`;
287
+ },
288
+ );
289
+
290
+ // Mistral: [TOOL_CALLS] [{"name":"search","arguments":{"query":"..."}}]
291
+ result = result.replace(
292
+ /\[TOOL_CALLS\]\s*\[\{"name"\s*:\s*"(\w+)"\s*,\s*"arguments"\s*:\s*\{([^}]*)\}\}\]/g,
293
+ (_, name, args) => {
294
+ if (!ALL_TOOLS.has(name)) return _;
295
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
296
+ const body = pairs[0]?.[2] || "";
297
+ return `<${name}>${body}</${name}>`;
298
+ },
299
+ );
300
+
301
+ return result;
302
+ }
267
303
  }
@@ -56,7 +56,15 @@ UPDATE known_entries
56
56
  SET
57
57
  fidelity = :fidelity
58
58
  , tokens = CASE
59
- WHEN :fidelity = 'summary' THEN countTokens(body)
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
+ )
60
68
  ELSE tokens_full
61
69
  END
62
70
  , updated_at = CURRENT_TIMESTAMP
@@ -74,7 +82,7 @@ WHERE run_id = :run_id AND path = :path;
74
82
  -- PREP: demote_path
75
83
  UPDATE known_entries
76
84
  SET
77
- fidelity = 'stored'
85
+ fidelity = 'archive'
78
86
  , tokens = 0
79
87
  , updated_at = CURRENT_TIMESTAMP
80
88
  WHERE run_id = :run_id AND path = :path;
@@ -84,7 +92,15 @@ UPDATE known_entries
84
92
  SET
85
93
  fidelity = :fidelity
86
94
  , tokens = CASE
87
- WHEN :fidelity = 'stored' THEN 0
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
+ )
88
104
  ELSE countTokens(body)
89
105
  END
90
106
  , updated_at = CURRENT_TIMESTAMP
@@ -133,7 +149,7 @@ WHERE
133
149
  -- PREP: demote_by_pattern
134
150
  UPDATE known_entries
135
151
  SET
136
- fidelity = 'stored'
152
+ fidelity = 'archive'
137
153
  , tokens = 0
138
154
  , updated_at = CURRENT_TIMESTAMP
139
155
  WHERE
@@ -173,3 +189,51 @@ WHERE
173
189
  run_id = :run_id
174
190
  AND hedmatch(:path, path)
175
191
  AND (:body IS NULL OR hedsearch(:body, body));
192
+
193
+ -- PREP: restore_summarized_prompts
194
+ -- Restore prompt entries demoted to summary by a recovery phase that was
195
+ -- interrupted (e.g. server crash). Safe to call unconditionally at loop
196
+ -- start: if the full prompt would overflow, Prompt Demotion handles it.
197
+ UPDATE known_entries
198
+ 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
+ )
214
+ , 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.
224
+ UPDATE known_entries
225
+ SET
226
+ fidelity = 'summary'
227
+ , status = 413
228
+ , tokens = COALESCE(
229
+ countTokens(json_extract(attributes, '$.summary'))
230
+ , countTokens(substr(body, 1, 80))
231
+ )
232
+ , updated_at = CURRENT_TIMESTAMP
233
+ WHERE
234
+ run_id = :run_id
235
+ AND turn = :turn
236
+ AND fidelity = 'full'
237
+ AND status < 400
238
+ AND scheme IN (SELECT name FROM schemes WHERE category = 'data')
239
+ RETURNING path;
@@ -1,3 +1,6 @@
1
1
  -- PREP: upsert_scheme
2
2
  INSERT OR REPLACE INTO schemes (name, model_visible, category)
3
3
  VALUES (:name, :model_visible, :category);
4
+
5
+ -- PREP: get_all_schemes
6
+ SELECT name, model_visible, category FROM schemes;
@@ -1,28 +1,14 @@
1
1
  /**
2
- * Token counting with tiktoken (o200k_base) and simple fallback.
3
- * o200k_base is the tokenizer for GPT-4o and newer OpenAI models.
4
- * Better multilingual and code handling than cl100k_base.
5
- * Exact counts vary by model tokenizer these are for budgeting, not billing.
2
+ * Token estimation. Conservative character-based approximation.
3
+ * RUMMY_TOKEN_DIVISOR controls characters per token.
4
+ * No external dependencies. The budget contract is exact.
5
+ * contextSize is the ceiling. countTokens is the measurement.
6
6
  */
7
7
 
8
- let encoder = null;
9
-
10
- try {
11
- const tiktoken = await import("tiktoken");
12
- encoder = tiktoken.get_encoding("o200k_base");
13
- } catch {
14
- // tiktoken unavailable — use character-based estimate
15
- }
8
+ const DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
9
+ if (!DIVISOR) throw new Error("RUMMY_TOKEN_DIVISOR must be a non-zero number");
16
10
 
17
11
  export function countTokens(text) {
18
12
  if (!text) return 0;
19
- if (encoder) {
20
- try {
21
- const tokens = encoder.encode(text);
22
- return tokens.length;
23
- } catch {
24
- // Fallback on encoding error
25
- }
26
- }
27
- return Math.ceil(text.length / 4);
13
+ return Math.ceil(text.length / DIVISOR);
28
14
  }
@@ -6,7 +6,9 @@ RETURNING id, sequence;
6
6
  -- PREP: update_turn_stats
7
7
  UPDATE turns
8
8
  SET
9
- prompt_tokens = :prompt_tokens
9
+ context_tokens = :context_tokens
10
+ , reasoning_content = :reasoning_content
11
+ , prompt_tokens = :prompt_tokens
10
12
  , cached_tokens = :cached_tokens
11
13
  , completion_tokens = :completion_tokens
12
14
  , reasoning_tokens = :reasoning_tokens
@@ -25,6 +27,18 @@ SELECT
25
27
  FROM turns
26
28
  WHERE run_id = :run_id;
27
29
 
30
+ -- PREP: get_turn_context_tokens
31
+ SELECT context_tokens
32
+ FROM turns
33
+ WHERE run_id = :run_id AND sequence = :sequence;
34
+
35
+ -- PREP: get_last_context_tokens
36
+ SELECT context_tokens
37
+ FROM turns
38
+ WHERE run_id = :run_id AND context_tokens > 0
39
+ ORDER BY sequence DESC
40
+ LIMIT 1;
41
+
28
42
  -- PREP: get_run_log
29
43
  SELECT ke.path, ke.status, ke.body, ke.attributes
30
44
  FROM known_entries AS ke
@@ -63,6 +63,13 @@ export default class HookRegistry {
63
63
  this.#events.get(tag).sort((a, b) => a.priority - b.priority);
64
64
  }
65
65
 
66
+ removeEvent(tag, callback) {
67
+ const hooks = this.#events.get(tag);
68
+ if (!hooks) return;
69
+ const idx = hooks.findIndex((h) => h.callback === callback);
70
+ if (idx !== -1) hooks.splice(idx, 1);
71
+ }
72
+
66
73
  async emitEvent(tag, ...args) {
67
74
  const hooks = this.#events.get(tag) || [];
68
75
  for (const h of hooks) {
@@ -11,6 +11,7 @@ export default function createHooks(debug = false) {
11
11
 
12
12
  const createEvent = (tag) => ({
13
13
  on: (callback, priority) => registry.addEvent(tag, callback, priority),
14
+ off: (callback) => registry.removeEvent(tag, callback),
14
15
  emit: (...args) => registry.emitEvent(tag, ...args),
15
16
  });
16
17
 
@@ -39,6 +40,7 @@ export default function createHooks(debug = false) {
39
40
  },
40
41
  },
41
42
  run: {
43
+ created: createEvent("run.created"),
42
44
  started: createEvent("run.started"),
43
45
  progress: createEvent("run.progress"),
44
46
  state: createEvent("run.state"),
@@ -47,10 +49,15 @@ export default function createHooks(debug = false) {
47
49
  completed: createEvent("run.step.completed"),
48
50
  },
49
51
  },
52
+ loop: {
53
+ started: createEvent("loop.started"),
54
+ completed: createEvent("loop.completed"),
55
+ },
50
56
  turn: {
51
57
  started: createEvent("turn.started"),
52
58
  response: createEvent("turn.response"),
53
59
  proposing: createEvent("turn.proposing"),
60
+ completed: createEvent("turn.completed"),
54
61
  },
55
62
  assembly: {
56
63
  system: createFilter("assembly.system"),
@@ -80,9 +87,17 @@ export default function createHooks(debug = false) {
80
87
  tools: createFilter("prompt.tools"),
81
88
  },
82
89
  entry: {
90
+ recording: createFilter("entry.recording"),
83
91
  created: createEvent("entry.created"),
84
92
  changed: createEvent("entry.changed"),
85
93
  },
94
+ tool: {
95
+ before: createEvent("tool.before"),
96
+ after: createEvent("tool.after"),
97
+ },
98
+ context: {
99
+ materialized: createEvent("context.materialized"),
100
+ },
86
101
  action: {},
87
102
  ui: {
88
103
  render: createEvent("ui.render"),
@@ -48,7 +48,12 @@ export default class PluginContext {
48
48
  return this.#schemes;
49
49
  }
50
50
 
51
- registerScheme({ name, modelVisible = 1, category = "result" } = {}) {
51
+ registerScheme({ name, modelVisible = 1, category = "logging" } = {}) {
52
+ if (!PluginContext.CATEGORIES.has(category)) {
53
+ throw new Error(
54
+ `Invalid category "${category}". Must be one of: ${[...PluginContext.CATEGORIES].join(", ")}`,
55
+ );
56
+ }
52
57
  this.#schemes.push({
53
58
  name: name || this.#name,
54
59
  model_visible: modelVisible,
@@ -56,6 +61,14 @@ export default class PluginContext {
56
61
  });
57
62
  }
58
63
 
64
+ static CATEGORIES = Object.freeze(
65
+ new Set(["data", "logging", "unknown", "prompt"]),
66
+ );
67
+
68
+ ensureTool() {
69
+ this.#hooks.tools.ensureTool(this.#name);
70
+ }
71
+
59
72
  /**
60
73
  * Register a named callback for this plugin.
61
74
  * "handler" registers the tool handler.
@@ -55,8 +55,20 @@ export default class RummyContext {
55
55
  return this.#context.loopId || null;
56
56
  }
57
57
 
58
- get noContext() {
59
- return this.#context.noContext === true;
58
+ get noRepo() {
59
+ return this.#context.noRepo === true;
60
+ }
61
+
62
+ get noInteraction() {
63
+ return this.#context.noInteraction === true;
64
+ }
65
+
66
+ get noWeb() {
67
+ return this.#context.noWeb === true;
68
+ }
69
+
70
+ get toolSet() {
71
+ return this.#context.toolSet || null;
60
72
  }
61
73
 
62
74
  get contextSize() {
@@ -89,7 +101,7 @@ export default class RummyContext {
89
101
 
90
102
  // --- Tool methods (same operations the model uses) ---
91
103
 
92
- async set({ path, body, status = 200, attributes } = {}) {
104
+ async set({ path, body, status = 200, fidelity, attributes } = {}) {
93
105
  if (!path) {
94
106
  const slugify = (await import("../sql/functions/slugify.js")).default;
95
107
  const base = slugify(body || "");
@@ -101,7 +113,7 @@ export default class RummyContext {
101
113
  path,
102
114
  body || "",
103
115
  status,
104
- { attributes, loopId: this.loopId },
116
+ { fidelity, attributes, loopId: this.loopId },
105
117
  );
106
118
  return path;
107
119
  }
@@ -1,3 +1,32 @@
1
+ // Tool display order: gather → reason → act → communicate.
2
+ // Position in the list implies priority to the model.
3
+ const TOOL_ORDER = [
4
+ "get",
5
+ "set",
6
+ "known",
7
+ "unknown",
8
+ "env",
9
+ "sh",
10
+ "rm",
11
+ "cp",
12
+ "mv",
13
+ "search",
14
+ "summarize",
15
+ "update",
16
+ "ask_user",
17
+ ];
18
+
19
+ function sortByPriority(names) {
20
+ return names.toSorted((a, b) => {
21
+ const ia = TOOL_ORDER.indexOf(a);
22
+ const ib = TOOL_ORDER.indexOf(b);
23
+ if (ia === -1 && ib === -1) return a.localeCompare(b);
24
+ if (ia === -1) return 1;
25
+ if (ib === -1) return 1;
26
+ return ia - ib;
27
+ });
28
+ }
29
+
1
30
  export default class ToolRegistry {
2
31
  #tools = new Map();
3
32
  #handlers = new Map();
@@ -5,12 +34,9 @@ export default class ToolRegistry {
5
34
 
6
35
  ensureTool(scheme) {
7
36
  if (this.#tools.has(scheme)) return;
8
- this.#tools.set(scheme, Object.freeze({ modes: new Set(["ask", "act"]) }));
37
+ this.#tools.set(scheme, Object.freeze({}));
9
38
  }
10
39
 
11
- // Exception: old register() removed. Plugins use core.on("handler")/core.on("full").
12
- // The only remaining caller pathway is ensureTool + onHandle + onView.
13
-
14
40
  get(name) {
15
41
  return this.#tools.get(name);
16
42
  }
@@ -39,10 +65,33 @@ export default class ToolRegistry {
39
65
  `Every tool must define how its entries appear in the model view.`,
40
66
  );
41
67
  }
68
+
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
+
42
75
  const fidelity = entry.fidelity || "full";
43
76
  const fn = fidelityMap.get(fidelity);
44
- if (!fn) return "";
45
- return await fn(entry);
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
+ }
88
+
89
+ // Fall back to summary attribute when plugin returns empty
90
+ if (fidelity === "summary" && summary && !body) {
91
+ return summary;
92
+ }
93
+
94
+ return body;
46
95
  }
47
96
 
48
97
  hasView(scheme) {
@@ -59,22 +108,31 @@ export default class ToolRegistry {
59
108
  }
60
109
  }
61
110
 
62
- get actTools() {
63
- return new Set(
64
- [...this.#tools.entries()]
65
- .filter(([, def]) => def.category === "act")
66
- .map(([name]) => name),
67
- );
68
- }
69
-
70
111
  get names() {
71
- return [...this.#tools.keys()];
112
+ return sortByPriority([...this.#tools.keys()]);
72
113
  }
73
114
 
74
- namesForMode(mode) {
75
- return [...this.#tools.entries()]
76
- .filter(([, def]) => def.modes.has(mode))
77
- .map(([name]) => name);
115
+ /**
116
+ * Compute the active tool set for a loop.
117
+ * All exclusions — mode, flags handled here. One mechanism.
118
+ */
119
+ resolveForLoop(
120
+ mode,
121
+ { noInteraction = false, noWeb = false, noProposals = false } = {},
122
+ ) {
123
+ const excluded = new Set();
124
+ if (mode === "ask") excluded.add("sh");
125
+ if (noInteraction) excluded.add("ask_user");
126
+ if (noWeb) excluded.add("search");
127
+ if (noProposals) {
128
+ excluded.add("ask_user");
129
+ excluded.add("env");
130
+ excluded.add("sh");
131
+ }
132
+ const names = sortByPriority(
133
+ [...this.#tools.keys()].filter((n) => !excluded.has(n)),
134
+ );
135
+ return new Set(names);
78
136
  }
79
137
 
80
138
  entries() {
@@ -90,18 +90,37 @@ export default class LlmProvider {
90
90
  }
91
91
 
92
92
  async getContextSize(model) {
93
+ // DB is the authority — check models table first
94
+ if (this.#db) {
95
+ const row = await this.#db.get_model_by_alias.get({ alias: model });
96
+ if (row?.context_length) return row.context_length;
97
+ }
98
+
99
+ // Fall back to API query
93
100
  const resolvedModel = await this.resolve(model);
101
+ let size;
94
102
  if (resolvedModel.startsWith("ollama/")) {
95
103
  const localModel = resolvedModel.replace("ollama/", "");
96
- return this.#getOllama().getContextSize(localModel);
97
- }
98
- if (resolvedModel.startsWith("openai/")) {
99
- return this.#getOpenAi().getContextSize(resolvedModel);
100
- }
101
- if (resolvedModel.startsWith("x.ai/")) {
104
+ size = await this.#getOllama().getContextSize(localModel);
105
+ } else if (resolvedModel.startsWith("openai/")) {
106
+ size = await this.#getOpenAi().getContextSize(resolvedModel);
107
+ } else if (resolvedModel.startsWith("x.ai/")) {
102
108
  const localModel = resolvedModel.replace("x.ai/", "");
103
- return this.#getXai().getContextSize(localModel);
109
+ size = await this.#getXai().getContextSize(localModel);
110
+ } else {
111
+ size = await this.#getOpenRouter().getContextSize(resolvedModel);
112
+ }
113
+
114
+ // Cache back to DB for next time
115
+ if (this.#db && size) {
116
+ await this.#db.update_model_context_length
117
+ .run({
118
+ alias: model,
119
+ context_length: size,
120
+ })
121
+ .catch(() => {});
104
122
  }
105
- return this.#getOpenRouter().getContextSize(resolvedModel);
123
+
124
+ return size;
106
125
  }
107
126
  }
@@ -49,6 +49,12 @@ export default class OpenAiClient {
49
49
  );
50
50
  msg.reasoning_content =
51
51
  parts.length > 0 ? [...new Set(parts)].join("\n") : null;
52
+
53
+ if (process.env.RUMMY_DEBUG === "true" && msg.reasoning_content) {
54
+ console.warn(
55
+ `[RUMMY] Reasoning (${msg.reasoning_content.length} chars): ${msg.reasoning_content.slice(0, 120)}`,
56
+ );
57
+ }
52
58
  }
53
59
 
54
60
  return data;
@@ -59,6 +65,20 @@ export default class OpenAiClient {
59
65
  const headers = { "Content-Type": "application/json" };
60
66
  if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
61
67
 
68
+ // Try /props first — llama.cpp exposes runtime n_ctx here
69
+ try {
70
+ const propsResponse = await fetch(`${this.#baseUrl}/props`, {
71
+ headers,
72
+ signal: AbortSignal.timeout(timeout),
73
+ });
74
+ if (propsResponse.ok) {
75
+ const props = await propsResponse.json();
76
+ const runtimeCtx = props?.default_generation_settings?.n_ctx;
77
+ if (runtimeCtx) return runtimeCtx;
78
+ }
79
+ } catch {}
80
+
81
+ // Fall back to /v1/models for training context
62
82
  const response = await fetch(`${this.#baseUrl}/v1/models`, {
63
83
  headers,
64
84
  signal: AbortSignal.timeout(timeout),