@possumtech/rummy 0.3.0 → 0.4.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 (65) hide show
  1. package/.env.example +13 -1
  2. package/PLUGINS.md +1 -1
  3. package/README.md +5 -1
  4. package/SPEC.md +211 -54
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +7 -3
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +183 -238
  9. package/src/agent/ContextAssembler.js +2 -0
  10. package/src/agent/KnownStore.js +36 -85
  11. package/src/agent/ResponseHealer.js +65 -31
  12. package/src/agent/TurnExecutor.js +284 -382
  13. package/src/agent/XmlParser.js +28 -4
  14. package/src/agent/known_queries.sql +1 -1
  15. package/src/agent/known_store.sql +32 -34
  16. package/src/agent/runs.sql +2 -2
  17. package/src/agent/tokens.js +1 -0
  18. package/src/agent/turns.sql +5 -0
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +2 -4
  21. package/src/hooks/ToolRegistry.js +8 -13
  22. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  23. package/src/plugins/budget/README.md +26 -30
  24. package/src/plugins/budget/budget.js +69 -36
  25. package/src/plugins/budget/recovery.js +47 -0
  26. package/src/plugins/cp/cp.js +1 -1
  27. package/src/plugins/cp/cpDoc.js +5 -10
  28. package/src/plugins/env/envDoc.js +3 -8
  29. package/src/plugins/get/get.js +70 -2
  30. package/src/plugins/get/getDoc.js +19 -16
  31. package/src/plugins/hedberg/matcher.js +10 -29
  32. package/src/plugins/helpers.js +2 -2
  33. package/src/plugins/instructions/instructions.js +3 -2
  34. package/src/plugins/instructions/preamble.md +33 -12
  35. package/src/plugins/known/known.js +66 -17
  36. package/src/plugins/known/knownDoc.js +7 -10
  37. package/src/plugins/mv/mv.js +18 -1
  38. package/src/plugins/mv/mvDoc.js +9 -10
  39. package/src/plugins/{current → performed}/README.md +4 -3
  40. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  41. package/src/plugins/policy/policy.js +47 -0
  42. package/src/plugins/previous/README.md +2 -1
  43. package/src/plugins/previous/previous.js +31 -25
  44. package/src/plugins/progress/README.md +1 -2
  45. package/src/plugins/progress/progress.js +10 -60
  46. package/src/plugins/prompt/prompt.js +10 -8
  47. package/src/plugins/rm/rm.js +27 -15
  48. package/src/plugins/rm/rmDoc.js +6 -11
  49. package/src/plugins/rpc/rpc.js +3 -1
  50. package/src/plugins/set/set.js +125 -92
  51. package/src/plugins/set/setDoc.js +28 -37
  52. package/src/plugins/sh/shDoc.js +2 -7
  53. package/src/plugins/summarize/summarize.js +7 -0
  54. package/src/plugins/summarize/summarizeDoc.js +6 -11
  55. package/src/plugins/telemetry/telemetry.js +14 -9
  56. package/src/plugins/think/think.js +12 -0
  57. package/src/plugins/think/thinkDoc.js +18 -0
  58. package/src/plugins/unknown/README.md +2 -1
  59. package/src/plugins/unknown/unknown.js +26 -4
  60. package/src/plugins/unknown/unknownDoc.js +9 -14
  61. package/src/plugins/update/update.js +7 -0
  62. package/src/plugins/update/updateDoc.js +6 -11
  63. package/src/server/ClientConnection.js +69 -45
  64. package/src/sql/v_model_context.sql +7 -17
  65. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -4,7 +4,7 @@ import { normalizeAttrs, 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
- const ALL_TOOLS = new Set([
7
+ export const ALL_TOOLS = new Set([
8
8
  ...STORE_TOOLS,
9
9
  "known",
10
10
  "sh",
@@ -13,6 +13,7 @@ const ALL_TOOLS = new Set([
13
13
  "summarize",
14
14
  "update",
15
15
  "unknown",
16
+ "think",
16
17
  ]);
17
18
 
18
19
  /**
@@ -100,7 +101,7 @@ function resolveCommand(name, attrs, rawBody) {
100
101
  if (name === "known") {
101
102
  const body = trimmed || a.body || "";
102
103
  const path = a.path || null;
103
- return { name, path, body };
104
+ return { name, ...a, path, body };
104
105
  }
105
106
 
106
107
  if (name === "get" || name === "rm") {
@@ -142,6 +143,8 @@ export default class XmlParser {
142
143
  * @param {string} content - Raw model response text
143
144
  * @returns {{ commands: Array, warnings: string[], unparsed: string }}
144
145
  */
146
+ static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
147
+
145
148
  static parse(content) {
146
149
  if (!content) return { commands: [], warnings: [], unparsed: "" };
147
150
 
@@ -153,13 +156,20 @@ export default class XmlParser {
153
156
  const textChunks = [];
154
157
  let current = null;
155
158
  let ended = false;
159
+ let capped = false;
156
160
 
157
161
  const parser = new Parser(
158
162
  {
159
163
  onopentag(name, attrs) {
164
+ if (capped) return;
160
165
  if (!ALL_TOOLS.has(name)) {
161
166
  if (current) {
162
- current.rawBody += `<${name}>`;
167
+ const attrStr = Object.entries(attrs)
168
+ .map(([k, v]) => v === "" ? k : `${k}="${v}"`)
169
+ .join(" ");
170
+ current.rawBody += attrStr
171
+ ? `<${name} ${attrStr}>`
172
+ : `<${name}>`;
163
173
  }
164
174
  return;
165
175
  }
@@ -174,10 +184,17 @@ export default class XmlParser {
174
184
  );
175
185
  }
176
186
 
187
+ if (commands.length >= XmlParser.MAX_COMMANDS) {
188
+ capped = true;
189
+ current = null;
190
+ return;
191
+ }
192
+
177
193
  current = { name, attrs, rawBody: "" };
178
194
  },
179
195
 
180
196
  ontext(text) {
197
+ if (capped) return;
181
198
  if (current) {
182
199
  current.rawBody += text;
183
200
  } else {
@@ -186,6 +203,7 @@ export default class XmlParser {
186
203
  },
187
204
 
188
205
  onclosetag(name, isImplied) {
206
+ if (capped) return;
189
207
  if (current && name === current.name) {
190
208
  if (ended) {
191
209
  warnings.push(`Unclosed <${name}> tag — content captured anyway`);
@@ -227,7 +245,7 @@ export default class XmlParser {
227
245
  parser.end();
228
246
 
229
247
  // Flush any unclosed tool tag
230
- if (current) {
248
+ if (current && !capped) {
231
249
  warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
232
250
  commands.push(
233
251
  resolveCommand(current.name, current.attrs, current.rawBody),
@@ -235,6 +253,12 @@ export default class XmlParser {
235
253
  current = null;
236
254
  }
237
255
 
256
+ if (capped) {
257
+ warnings.push(
258
+ `Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
259
+ );
260
+ }
261
+
238
262
  const unparsed = textChunks.join("").trim();
239
263
  return { commands, warnings, unparsed };
240
264
  }
@@ -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,18 +53,6 @@ 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
 
@@ -74,8 +60,8 @@ WHERE run_id = :run_id AND hedmatch(:pattern, path) AND scheme IS NULL;
74
60
  UPDATE known_entries
75
61
  SET
76
62
  fidelity = 'full'
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
 
@@ -83,26 +69,14 @@ WHERE run_id = :run_id AND path = :path;
83
69
  UPDATE known_entries
84
70
  SET
85
71
  fidelity = 'archive'
86
- , tokens = 0
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
 
@@ -138,8 +112,8 @@ WHERE run_id = :run_id AND path = :path;
138
112
  UPDATE known_entries
139
113
  SET
140
114
  fidelity = 'full'
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
@@ -150,7 +124,6 @@ WHERE
150
124
  UPDATE known_entries
151
125
  SET
152
126
  fidelity = 'archive'
153
- , tokens = 0
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,10 +155,35 @@ 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
189
161
  run_id = :run_id
190
162
  AND hedmatch(:path, path)
191
163
  AND (:body IS NULL OR hedsearch(:body, body));
164
+
165
+ -- PREP: restore_summarized_prompts
166
+ -- Restore prompt entries demoted to summary by a recovery phase that was
167
+ -- interrupted (e.g. server crash). Safe to call unconditionally at loop
168
+ -- start: if the full prompt would overflow, Prompt Demotion handles it.
169
+ UPDATE known_entries
170
+ SET
171
+ fidelity = 'full'
172
+ , updated_at = CURRENT_TIMESTAMP
173
+ WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'summary';
174
+
175
+ -- PREP: demote_turn_entries
176
+ -- Demote all full entries from a turn to summary with 413 status.
177
+ -- Tokens unchanged — always reports full cost regardless of fidelity.
178
+ UPDATE known_entries
179
+ SET
180
+ fidelity = 'summary'
181
+ , status = 413
182
+ , updated_at = CURRENT_TIMESTAMP
183
+ WHERE
184
+ run_id = :run_id
185
+ AND turn = :turn
186
+ AND fidelity = 'full'
187
+ AND status < 400
188
+ RETURNING path, tokens;
189
+
@@ -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
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
9
+ if (!DIVISOR) throw new Error("RUMMY_TOKEN_DIVISOR must be a non-zero number");
9
10
 
10
11
  export function countTokens(text) {
11
12
  if (!text) return 0;
@@ -27,6 +27,11 @@ SELECT
27
27
  FROM turns
28
28
  WHERE run_id = :run_id;
29
29
 
30
+ -- PREP: get_turn_context_tokens
31
+ SELECT context_tokens
32
+ FROM turns
33
+ WHERE run_id = :run_id AND sequence = :sequence;
34
+
30
35
  -- PREP: get_last_context_tokens
31
36
  SELECT context_tokens
32
37
  FROM turns
@@ -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
 
@@ -55,6 +56,7 @@ export default function createHooks(debug = false) {
55
56
  turn: {
56
57
  started: createEvent("turn.started"),
57
58
  response: createEvent("turn.response"),
59
+ proposal: createEvent("turn.proposal"),
58
60
  proposing: createEvent("turn.proposing"),
59
61
  completed: createEvent("turn.completed"),
60
62
  },
@@ -73,10 +75,6 @@ export default function createHooks(debug = false) {
73
75
  started: createEvent("act.started"),
74
76
  completed: createEvent("act.completed"),
75
77
  },
76
- panic: {
77
- started: createEvent("panic.started"),
78
- completed: createEvent("panic.completed"),
79
- },
80
78
  llm: {
81
79
  request: {
82
80
  started: createEvent("llm.request.started"),
@@ -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) {
@@ -118,19 +119,13 @@ export default class ToolRegistry {
118
119
  */
119
120
  resolveForLoop(
120
121
  mode,
121
- { noInteraction = false, noWeb = false, noBench = false } = {},
122
+ { noInteraction = false, noWeb = false, noProposals = false } = {},
122
123
  ) {
123
124
  const excluded = new Set();
124
125
  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
126
  if (noInteraction) excluded.add("ask_user");
132
127
  if (noWeb) excluded.add("search");
133
- if (noBench) {
128
+ if (noProposals) {
134
129
  excluded.add("ask_user");
135
130
  excluded.add("env");
136
131
  excluded.add("sh");
@@ -2,27 +2,22 @@
2
2
  // Text goes to the model. Rationale stays in source.
3
3
  // Changing ANY line requires reading ALL rationales first.
4
4
  const LINES = [
5
- // --- Syntax: question attr + options in body
6
5
  ['## <ask_user question="[Question?]">[option1; option2; ...]</ask_user>'],
7
-
8
- // --- Constraints FIRST: frames correct usage before examples
9
6
  [
10
7
  "* YOU SHOULD use for decisions, preferences, or approvals the user must make",
11
- "Positive framing. Shows what ask_user IS for, not just what it isn't.",
8
+ "Positive framing. Shows what ask_user IS for.",
12
9
  ],
13
10
  [
14
11
  "* YOU SHOULD use <get> to find information before asking the user",
15
- "Gentle redirect. Encourages self-sufficiency without forbidding interaction.",
12
+ "Gentle redirect. Encourages self-sufficiency.",
16
13
  ],
17
-
18
- // --- Examples: genuine decision points where user input is valuable
19
14
  [
20
15
  'Example: <ask_user question="Which test framework?">Mocha; Jest; Node Native</ask_user>',
21
16
  "Preference decision. Model truly cannot know this without asking.",
22
17
  ],
23
18
  [
24
19
  'Example: <ask_user question="Deploy to staging or production?">staging; production</ask_user>',
25
- "Consequential action. Shows ask_user for high-stakes choices.",
20
+ "Consequential action. High-stakes choice.",
26
21
  ],
27
22
  ];
28
23
 
@@ -1,43 +1,39 @@
1
1
  # budget
2
2
 
3
- Context ceiling enforcement and panic mode recovery.
3
+ Context ceiling enforcement.
4
4
 
5
- ## Files
6
-
7
- - **budget.js** — Plugin. Pre-LLM enforce, BudgetGuard activation,
8
- panic prompt generation.
9
- - **BudgetGuard.js** — Write-layer gate. Installed on KnownStore during
10
- dispatch. Checks token delta on every upsert, promote, and body update.
11
-
12
- ## Registration
5
+ ## Design
13
6
 
14
- - **Hook**: `hooks.budget.enforce` pre-LLM ceiling check.
15
- - **Hook**: `hooks.budget.activate(store, contextSize, assembledTokens)` install guard.
16
- - **Hook**: `hooks.budget.deactivate(store)` remove guard.
17
- - **Hook**: `hooks.budget.panicPrompt({ shortfall, assembledTokens, contextSize })` — generate panic prompt.
7
+ Ceiling = `floor(contextSize × 0.9)`. The 10% headroom is the system's
8
+ operating room for graceful overflow handling. No per-write gating
9
+ tools run uninterrupted. Enforcement happens at boundaries.
18
10
 
19
- ## Budget Contract
11
+ ## Enforcement Points
20
12
 
21
- `contextSize` is the ceiling. `countTokens()` is the measurement.
22
- Over = 413. Under = 200. No margins.
13
+ 1. **Pre-LLM enforce** (`budget.enforce`): checks assembled context
14
+ before the LLM call. If over ceiling Prompt Demotion (summarize
15
+ the incoming prompt). Model runs in the headroom.
23
16
 
24
- ## BudgetGuard
17
+ 2. **Post-dispatch Turn Demotion**: after all tools dispatch, check
18
+ context. If over ceiling → demote ALL entries from this turn to
19
+ summary (every scheme except `budget`). Write `budget://` entry
20
+ listing what was demoted. Model sees it next turn and adapts.
25
21
 
26
- Installed on KnownStore by TurnExecutor before dispatch, cleared in
27
- `finally`. Gates `upsert()`, `promoteByPattern()`, `updateBodyByPattern()`.
22
+ 3. **LLM rejection** (`isContextExceeded`): turn-1 token estimate
23
+ drift causes LLM to reject. Same demotion pattern.
28
24
 
29
- Exemptions: `status >= 400` (error entries), `model_visible = 0` (audit),
30
- `fidelity = "archive"` (not in context).
25
+ 4. **AgentLoop recovery**: pre-LLM 413 that Prompt Demotion can't
26
+ resolve. Batch-demote all full entries, budget entry, model gets
27
+ recovery turns. 3 strikes without progress → hard 413 to client.
28
+ Only path where 413 reaches the client.
31
29
 
32
- On first violation: `BudgetExceeded` thrown, guard trips, all subsequent
33
- writes fail. TurnExecutor catches per-tool, writes 413 result entry.
30
+ ## Files
34
31
 
35
- ## Panic Mode
32
+ - **budget.js** — Plugin. Pre-LLM enforce hook.
33
+ - **BudgetGuard.js** — `BudgetExceeded` error type, `delta` utility.
36
34
 
37
- When a new prompt exceeds the ceiling, AgentLoop enqueues a panic loop.
38
- The model receives the exact shortfall and must free space using core
39
- tools (get, set, known, unknown, rm, mv, cp, summarize, update).
40
- Excluded: sh, env, search, ask_user.
35
+ ## Registration
41
36
 
42
- Strike system: 3 consecutive turns without context reduction = hard 413.
43
- Any reduction resets the counter. One panic attempt per drain cycle.
37
+ - **Hook**: `hooks.budget.enforce` pre-LLM ceiling check.
38
+ - **Scheme**: `budget://` logging category, model-visible. `onView`
39
+ renders body at all fidelity levels (summary shows full content).
@@ -1,56 +1,44 @@
1
1
  import { countTokens } from "../../agent/tokens.js";
2
- import BudgetGuard, { BudgetExceeded } from "./BudgetGuard.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");
3
5
 
4
6
  function measureMessages(messages) {
5
7
  return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
6
8
  }
7
9
 
8
- export { BudgetExceeded };
9
-
10
10
  export default class Budget {
11
11
  #core;
12
12
 
13
13
  constructor(core) {
14
14
  this.#core = core;
15
+ core.registerScheme({
16
+ name: "budget",
17
+ modelVisible: 1,
18
+ category: "logging",
19
+ });
20
+ core.hooks.tools.onView("budget", (entry) => entry.body);
15
21
  core.hooks.budget = {
16
22
  enforce: this.enforce.bind(this),
17
- activate: this.activate.bind(this),
18
- deactivate: this.deactivate.bind(this),
19
- panicPrompt: Budget.panicPrompt,
20
- BudgetExceeded,
23
+ postDispatch: this.postDispatch.bind(this),
21
24
  };
22
25
  }
23
26
 
24
- static panicPrompt({ assembledTokens, contextSize }) {
25
- const target = Math.floor(contextSize * 0.75);
26
- const mustFree = assembledTokens - target;
27
- return [
28
- `CONTEXT OVERFLOW: ${assembledTokens} tokens, ceiling ${contextSize}.`,
29
- `YOU MUST free ${mustFree} tokens to get below ${target} (75%).`,
30
- "YOU MUST NOT load or create new content. Only reduce.",
31
- "",
32
- "<knowns> above shows each entry with its token count.",
33
- "Target the largest entries first.",
34
- '<rm path="..."/> to delete entries you no longer need.',
35
- '<set path="..." fidelity="summary" summary="keywords"/> to compress.',
36
- '<set path="..." fidelity="archive"/> to archive out of context.',
37
- "<summarize/> when done. <update/> if still working.",
38
- ].join("\n");
39
- }
40
-
41
- async enforce({ contextSize, messages, rows }) {
27
+ async enforce({ contextSize, messages, rows, lastPromptTokens = 0 }) {
42
28
  if (!contextSize) {
43
29
  return { messages, rows, demoted: [], assembledTokens: 0, status: 200 };
44
30
  }
45
31
 
46
- const assembledTokens = measureMessages(messages);
32
+ const assembledTokens =
33
+ lastPromptTokens > 0 ? lastPromptTokens : measureMessages(messages);
47
34
 
48
35
  console.warn(
49
- `[RUMMY] Budget enforce: ${assembledTokens} tokens, ceiling ${contextSize}, ${rows.length} rows`,
36
+ `[RUMMY] Budget enforce: ${assembledTokens} tokens (${lastPromptTokens > 0 ? "actual" : "estimated"}), ceiling ${contextSize}, ${rows.length} rows`,
50
37
  );
51
38
 
52
- if (assembledTokens > contextSize) {
53
- const overflow = assembledTokens - contextSize;
39
+ const ceiling = Math.floor(contextSize * CEILING_RATIO);
40
+ if (assembledTokens > ceiling) {
41
+ const overflow = assembledTokens - ceiling;
54
42
  console.warn(
55
43
  `[RUMMY] Budget 413: ${assembledTokens} tokens > ${contextSize} ceiling (${overflow} over)`,
56
44
  );
@@ -67,13 +55,58 @@ export default class Budget {
67
55
  return { messages, rows, demoted: [], assembledTokens, status: 200 };
68
56
  }
69
57
 
70
- activate(store, contextSize, assembledTokens) {
71
- const guard = new BudgetGuard(contextSize, assembledTokens);
72
- store.budgetGuard = guard;
73
- return guard;
74
- }
58
+ async postDispatch({
59
+ contextSize,
60
+ messages,
61
+ rows,
62
+ runId,
63
+ loopId,
64
+ turn,
65
+ db,
66
+ store,
67
+ }) {
68
+ if (!contextSize) return null;
69
+
70
+ const postBudget = await this.enforce({
71
+ contextSize,
72
+ messages,
73
+ rows,
74
+ lastPromptTokens: 0,
75
+ });
76
+
77
+ if (postBudget.status !== 413) return null;
75
78
 
76
- deactivate(store) {
77
- store.budgetGuard = null;
79
+ // Demote this turn's entries
80
+ const demotedEntries = await db.demote_turn_entries.all({
81
+ run_id: runId,
82
+ turn,
83
+ });
84
+
85
+ // Also summarize the prompt
86
+ const promptRow = rows.find((r) => r.scheme === "prompt");
87
+ if (promptRow) {
88
+ await store.setFidelity(runId, promptRow.path, "summary");
89
+ }
90
+
91
+ // Write budget entry
92
+ const ceiling = Math.floor(contextSize * CEILING_RATIO);
93
+ const totalDemoted = demotedEntries.reduce((s, r) => s + r.tokens, 0);
94
+ const pathList = demotedEntries
95
+ .map((r) => `${r.path} (${r.tokens} tokens)`)
96
+ .join("\n");
97
+ const body = [
98
+ `Error 413: Context overflowed by ${postBudget.overflow} tokens.`,
99
+ `${demotedEntries.length} entries (${totalDemoted} tokens total) demoted. Budget: ${ceiling} tokens.`,
100
+ pathList,
101
+ ].join("\n");
102
+
103
+ await store.upsert(runId, turn, `budget://${loopId}/${turn}`, body, 413, {
104
+ loopId,
105
+ });
106
+
107
+ return {
108
+ target: ceiling,
109
+ promptPath: promptRow?.path ?? null,
110
+ };
78
111
  }
79
112
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pure recovery state transition — exported for testing.
3
+ *
4
+ * @param {object|null} recovery Current recovery state.
5
+ * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
6
+ * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
7
+ */
8
+ export function advanceRecovery(recovery, result) {
9
+ // Initialise or update recovery state from a new Turn Demotion event.
10
+ if (result.budgetRecovery) {
11
+ if (!recovery) {
12
+ recovery = {
13
+ target: result.budgetRecovery.target,
14
+ promptPath: result.budgetRecovery.promptPath,
15
+ strikes: 0,
16
+ lastTokens: result.assembledTokens,
17
+ };
18
+ } else {
19
+ // Re-overflow during recovery: tighten target, don't count as strike.
20
+ recovery = {
21
+ ...recovery,
22
+ target: Math.min(recovery.target, result.budgetRecovery.target),
23
+ };
24
+ }
25
+ }
26
+
27
+ if (recovery === null) return { next: null, action: null, promptPath: null };
28
+
29
+ const current = result.assembledTokens;
30
+
31
+ if (current <= recovery.target) {
32
+ return { next: null, action: "restore", promptPath: recovery.promptPath };
33
+ }
34
+
35
+ const noProgress = current >= recovery.lastTokens && !result.budgetRecovery;
36
+ const strikes = noProgress ? recovery.strikes + 1 : 0;
37
+
38
+ if (strikes >= 3) {
39
+ return { next: null, action: "hard413", promptPath: null };
40
+ }
41
+
42
+ return {
43
+ next: { ...recovery, strikes, lastTokens: current },
44
+ action: null,
45
+ promptPath: null,
46
+ };
47
+ }
@@ -19,7 +19,7 @@ export default class Cp {
19
19
  async handler(entry, rummy) {
20
20
  const { entries: store, sequence: turn, runId, loopId } = rummy;
21
21
  const { path, to } = entry.attributes;
22
- const VALID = { stored: 1, summary: 1, index: 1, full: 1 };
22
+ const VALID = { stored: 1, summary: 1, index: 1, full: 1, archive: 1 };
23
23
  const fidelity = VALID[entry.attributes.fidelity]
24
24
  ? entry.attributes.fidelity
25
25
  : undefined;