@possumtech/rummy 0.2.8 → 0.3.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 (108) hide show
  1. package/.env.example +11 -1
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +6 -4
  6. package/package.json +13 -5
  7. package/src/agent/AgentLoop.js +166 -15
  8. package/src/agent/ContextAssembler.js +18 -4
  9. package/src/agent/KnownStore.js +127 -13
  10. package/src/agent/ProjectAgent.js +4 -1
  11. package/src/agent/ResponseHealer.js +21 -1
  12. package/src/agent/TurnExecutor.js +365 -175
  13. package/src/agent/XmlParser.js +72 -39
  14. package/src/agent/known_store.sql +20 -4
  15. package/src/agent/schemes.sql +3 -0
  16. package/src/agent/tokens.js +6 -21
  17. package/src/agent/turns.sql +10 -1
  18. package/src/hooks/Hooks.js +18 -0
  19. package/src/hooks/PluginContext.js +14 -1
  20. package/src/hooks/RummyContext.js +16 -4
  21. package/src/hooks/ToolRegistry.js +83 -19
  22. package/src/llm/LlmProvider.js +27 -8
  23. package/src/llm/OpenAiClient.js +20 -0
  24. package/src/llm/OpenRouterClient.js +24 -2
  25. package/src/llm/XaiClient.js +47 -2
  26. package/src/plugins/ask_user/README.md +4 -4
  27. package/src/plugins/ask_user/ask_user.js +5 -5
  28. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  29. package/src/plugins/budget/BudgetGuard.js +74 -0
  30. package/src/plugins/budget/README.md +43 -0
  31. package/src/plugins/budget/budget.js +79 -0
  32. package/src/plugins/cp/README.md +5 -4
  33. package/src/plugins/cp/cp.js +10 -6
  34. package/src/plugins/cp/cpDoc.js +29 -0
  35. package/src/plugins/current/README.md +4 -4
  36. package/src/plugins/current/current.js +9 -6
  37. package/src/plugins/engine/engine.sql +1 -8
  38. package/src/plugins/engine/turn_context.sql +4 -9
  39. package/src/plugins/env/README.md +3 -4
  40. package/src/plugins/env/env.js +5 -5
  41. package/src/plugins/env/envDoc.js +29 -0
  42. package/src/plugins/file/README.md +9 -12
  43. package/src/plugins/file/file.js +34 -35
  44. package/src/plugins/get/README.md +2 -2
  45. package/src/plugins/get/get.js +6 -5
  46. package/src/plugins/get/getDoc.js +41 -0
  47. package/src/plugins/hedberg/hedberg.js +2 -1
  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 +9 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +29 -17
  57. package/src/plugins/known/knownDoc.js +33 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +10 -6
  60. package/src/plugins/mv/mvDoc.js +31 -0
  61. package/src/plugins/persona/persona.js +78 -0
  62. package/src/plugins/previous/README.md +2 -2
  63. package/src/plugins/previous/previous.js +9 -6
  64. package/src/plugins/progress/progress.js +41 -15
  65. package/src/plugins/prompt/README.md +5 -5
  66. package/src/plugins/prompt/prompt.js +18 -13
  67. package/src/plugins/rm/README.md +4 -4
  68. package/src/plugins/rm/rm.js +5 -5
  69. package/src/plugins/rm/rmDoc.js +30 -0
  70. package/src/plugins/rpc/README.md +15 -28
  71. package/src/plugins/rpc/rpc.js +42 -77
  72. package/src/plugins/set/README.md +13 -12
  73. package/src/plugins/set/set.js +60 -5
  74. package/src/plugins/set/setDoc.js +45 -0
  75. package/src/plugins/sh/README.md +4 -4
  76. package/src/plugins/sh/sh.js +5 -5
  77. package/src/plugins/sh/shDoc.js +29 -0
  78. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  79. package/src/plugins/summarize/README.md +6 -5
  80. package/src/plugins/summarize/summarize.js +7 -6
  81. package/src/plugins/summarize/summarizeDoc.js +33 -0
  82. package/src/plugins/telemetry/telemetry.js +3 -1
  83. package/src/plugins/think/README.md +20 -0
  84. package/src/plugins/think/think.js +5 -0
  85. package/src/plugins/unknown/README.md +5 -5
  86. package/src/plugins/unknown/unknown.js +9 -7
  87. package/src/plugins/unknown/unknownDoc.js +31 -0
  88. package/src/plugins/update/README.md +3 -8
  89. package/src/plugins/update/update.js +7 -6
  90. package/src/plugins/update/updateDoc.js +33 -0
  91. package/src/server/RpcRegistry.js +52 -4
  92. package/src/sql/v_model_context.sql +16 -21
  93. package/src/plugins/ask_user/docs.md +0 -2
  94. package/src/plugins/cp/docs.md +0 -2
  95. package/src/plugins/env/docs.md +0 -4
  96. package/src/plugins/get/docs.md +0 -10
  97. package/src/plugins/known/docs.md +0 -3
  98. package/src/plugins/mv/docs.md +0 -2
  99. package/src/plugins/rm/docs.md +0 -6
  100. package/src/plugins/set/docs.md +0 -6
  101. package/src/plugins/sh/docs.md +0 -2
  102. package/src/plugins/skills/README.md +0 -25
  103. package/src/plugins/store/README.md +0 -20
  104. package/src/plugins/store/docs.md +0 -6
  105. package/src/plugins/store/store.js +0 -63
  106. package/src/plugins/summarize/docs.md +0 -4
  107. package/src/plugins/unknown/docs.md +0 -5
  108. package/src/plugins/update/docs.md +0 -4
@@ -1,17 +1,9 @@
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
- ]);
6
+ const STORE_TOOLS = new Set(["get", "rm", "set", "mv", "cp", "search"]);
15
7
  const ALL_TOOLS = new Set([
16
8
  ...STORE_TOOLS,
17
9
  "known",
@@ -51,31 +43,10 @@ function resolveCommand(name, attrs, rawBody) {
51
43
  };
52
44
  }
53
45
  }
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
- }
46
+ // JSON-style { search, replace }
47
+ const jsonEdit = parseJsonEdit(trimmed);
48
+ if (jsonEdit) {
49
+ return { name, path: a.path, ...jsonEdit };
79
50
  }
80
51
  // Sed syntax: s/search/replace/flags — supports chained commands
81
52
  if (trimmed.startsWith("s/")) {
@@ -116,9 +87,9 @@ function resolveCommand(name, attrs, rawBody) {
116
87
  preview: a.preview,
117
88
  };
118
89
  }
119
- // Plain write create/overwrite
90
+ // Plain write or fidelity change
120
91
  const body = trimmed || a.body || "";
121
- return { name, path: a.path, body, preview: a.preview };
92
+ return { name, ...a, body };
122
93
  }
123
94
 
124
95
  if (name === "summarize" || name === "update" || name === "unknown") {
@@ -132,7 +103,7 @@ function resolveCommand(name, attrs, rawBody) {
132
103
  return { name, path, body };
133
104
  }
134
105
 
135
- if (name === "get" || name === "store" || name === "rm") {
106
+ if (name === "get" || name === "rm") {
136
107
  const path = a.path || trimmed || null;
137
108
  return { name, path, body: a.body, preview: a.preview };
138
109
  }
@@ -174,6 +145,9 @@ export default class XmlParser {
174
145
  static parse(content) {
175
146
  if (!content) return { commands: [], warnings: [], unparsed: "" };
176
147
 
148
+ // Normalize native tool call formats to rummy XML
149
+ const normalized = XmlParser.#normalizeToolCalls(content);
150
+
177
151
  const commands = [];
178
152
  const warnings = [];
179
153
  const textChunks = [];
@@ -248,7 +222,7 @@ export default class XmlParser {
248
222
  },
249
223
  );
250
224
 
251
- parser.write(content);
225
+ parser.write(normalized);
252
226
  ended = true;
253
227
  parser.end();
254
228
 
@@ -264,4 +238,63 @@ export default class XmlParser {
264
238
  const unparsed = textChunks.join("").trim();
265
239
  return { commands, warnings, unparsed };
266
240
  }
241
+
242
+ /**
243
+ * Normalize native tool call formats to rummy XML.
244
+ * Models sometimes emit their training-format tool calls instead of
245
+ * our XML tags. The intent is unambiguous — translate silently.
246
+ */
247
+ static #normalizeToolCalls(content) {
248
+ // Gemma: ```tool_code\n<xml>...\n``` — strip code fences around valid XML
249
+ let result = content.replace(
250
+ /```(?:tool_code|tool_command|xml)\n([\s\S]*?)```/g,
251
+ (_, inner) => inner.trim(),
252
+ );
253
+
254
+ // Qwen/gemma: <|tool_call>call:NAME{key:"value"}<tool_call|>
255
+ result = result.replace(
256
+ /<\|tool_call>call:(\w+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
257
+ (_, name, params) => {
258
+ if (!ALL_TOOLS.has(name)) return _;
259
+ const valueMatch = params.match(/["']([^"']+)["']/);
260
+ const body = valueMatch?.[1] || "";
261
+ return `<${name}>${body}</${name}>`;
262
+ },
263
+ );
264
+
265
+ // OpenAI function_call JSON: {"name":"search","arguments":{"query":"..."}}
266
+ result = result.replace(
267
+ /\{"name"\s*:\s*"(\w+)"\s*,\s*"arguments"\s*:\s*\{([^}]*)\}\}/g,
268
+ (_, name, args) => {
269
+ if (!ALL_TOOLS.has(name)) return _;
270
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
271
+ const body = pairs[0]?.[2] || "";
272
+ return `<${name}>${body}</${name}>`;
273
+ },
274
+ );
275
+
276
+ // Anthropic: <tool_use><name>search</name><input>{"query":"..."}</input></tool_use>
277
+ result = result.replace(
278
+ /<tool_use>\s*<name>(\w+)<\/name>\s*<input>\{([^}]*)\}<\/input>\s*<\/tool_use>/g,
279
+ (_, name, args) => {
280
+ if (!ALL_TOOLS.has(name)) return _;
281
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
282
+ const body = pairs[0]?.[2] || "";
283
+ return `<${name}>${body}</${name}>`;
284
+ },
285
+ );
286
+
287
+ // Mistral: [TOOL_CALLS] [{"name":"search","arguments":{"query":"..."}}]
288
+ result = result.replace(
289
+ /\[TOOL_CALLS\]\s*\[\{"name"\s*:\s*"(\w+)"\s*,\s*"arguments"\s*:\s*\{([^}]*)\}\}\]/g,
290
+ (_, name, args) => {
291
+ if (!ALL_TOOLS.has(name)) return _;
292
+ const pairs = [...args.matchAll(/"(\w+)"\s*:\s*"([^"]*)"/g)];
293
+ const body = pairs[0]?.[2] || "";
294
+ return `<${name}>${body}</${name}>`;
295
+ },
296
+ );
297
+
298
+ return result;
299
+ }
267
300
  }
@@ -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
@@ -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,13 @@
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);
16
9
 
17
10
  export function countTokens(text) {
18
11
  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);
12
+ return Math.ceil(text.length / DIVISOR);
28
13
  }
@@ -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,13 @@ SELECT
25
27
  FROM turns
26
28
  WHERE run_id = :run_id;
27
29
 
30
+ -- PREP: get_last_context_tokens
31
+ SELECT context_tokens
32
+ FROM turns
33
+ WHERE run_id = :run_id AND context_tokens > 0
34
+ ORDER BY sequence DESC
35
+ LIMIT 1;
36
+
28
37
  -- PREP: get_run_log
29
38
  SELECT ke.path, ke.status, ke.body, ke.attributes
30
39
  FROM known_entries AS ke
@@ -39,6 +39,7 @@ export default function createHooks(debug = false) {
39
39
  },
40
40
  },
41
41
  run: {
42
+ created: createEvent("run.created"),
42
43
  started: createEvent("run.started"),
43
44
  progress: createEvent("run.progress"),
44
45
  state: createEvent("run.state"),
@@ -47,10 +48,15 @@ export default function createHooks(debug = false) {
47
48
  completed: createEvent("run.step.completed"),
48
49
  },
49
50
  },
51
+ loop: {
52
+ started: createEvent("loop.started"),
53
+ completed: createEvent("loop.completed"),
54
+ },
50
55
  turn: {
51
56
  started: createEvent("turn.started"),
52
57
  response: createEvent("turn.response"),
53
58
  proposing: createEvent("turn.proposing"),
59
+ completed: createEvent("turn.completed"),
54
60
  },
55
61
  assembly: {
56
62
  system: createFilter("assembly.system"),
@@ -67,6 +73,10 @@ export default function createHooks(debug = false) {
67
73
  started: createEvent("act.started"),
68
74
  completed: createEvent("act.completed"),
69
75
  },
76
+ panic: {
77
+ started: createEvent("panic.started"),
78
+ completed: createEvent("panic.completed"),
79
+ },
70
80
  llm: {
71
81
  request: {
72
82
  started: createEvent("llm.request.started"),
@@ -80,9 +90,17 @@ export default function createHooks(debug = false) {
80
90
  tools: createFilter("prompt.tools"),
81
91
  },
82
92
  entry: {
93
+ recording: createFilter("entry.recording"),
83
94
  created: createEvent("entry.created"),
84
95
  changed: createEvent("entry.changed"),
85
96
  },
97
+ tool: {
98
+ before: createEvent("tool.before"),
99
+ after: createEvent("tool.after"),
100
+ },
101
+ context: {
102
+ materialized: createEvent("context.materialized"),
103
+ },
86
104
  action: {},
87
105
  ui: {
88
106
  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,37 @@ 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, noBench = false } = {},
122
+ ) {
123
+ const excluded = new Set();
124
+ if (mode === "ask") excluded.add("sh");
125
+ if (mode === "panic") {
126
+ excluded.add("sh");
127
+ excluded.add("env");
128
+ excluded.add("search");
129
+ excluded.add("ask_user");
130
+ }
131
+ if (noInteraction) excluded.add("ask_user");
132
+ if (noWeb) excluded.add("search");
133
+ if (noBench) {
134
+ excluded.add("ask_user");
135
+ excluded.add("env");
136
+ excluded.add("sh");
137
+ }
138
+ const names = sortByPriority(
139
+ [...this.#tools.keys()].filter((n) => !excluded.has(n)),
140
+ );
141
+ return new Set(names);
78
142
  }
79
143
 
80
144
  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),
@@ -72,7 +72,29 @@ export default class OpenRouterClient {
72
72
  return data;
73
73
  }
74
74
 
75
- async getContextSize(_model) {
76
- return Number(process.env.RUMMY_CONTEXT_SIZE) || DEFAULT_CONTEXT_SIZE;
75
+ #contextCache = new Map();
76
+
77
+ async getContextSize(model) {
78
+ if (process.env.RUMMY_CONTEXT_SIZE)
79
+ return Number(process.env.RUMMY_CONTEXT_SIZE);
80
+
81
+ if (this.#contextCache.has(model)) return this.#contextCache.get(model);
82
+
83
+ try {
84
+ const res = await fetch(`${this.#baseUrl}/models`, {
85
+ headers: { Authorization: `Bearer ${this.#apiKey}` },
86
+ signal: AbortSignal.timeout(5000),
87
+ });
88
+ if (res.ok) {
89
+ const data = await res.json();
90
+ const entry = data.data?.find((m) => m.id === model);
91
+ if (entry?.context_length) {
92
+ this.#contextCache.set(model, entry.context_length);
93
+ return entry.context_length;
94
+ }
95
+ }
96
+ } catch {}
97
+
98
+ return DEFAULT_CONTEXT_SIZE;
77
99
  }
78
100
  }