@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
@@ -6,6 +6,10 @@ import ResponseHealer from "./ResponseHealer.js";
6
6
  import { countTokens } from "./tokens.js";
7
7
  import XmlParser from "./XmlParser.js";
8
8
 
9
+ const ACTION_SCHEMES = new Set(["get", "set", "rm", "mv", "cp", "sh", "env", "search"]);
10
+ const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
11
+ const READ_SCHEMES = new Set(["get", "env", "search"]);
12
+
9
13
  export default class TurnExecutor {
10
14
  #db;
11
15
  #llmProvider;
@@ -19,6 +23,68 @@ export default class TurnExecutor {
19
23
  this.#knownStore = knownStore;
20
24
  }
21
25
 
26
+ /**
27
+ * Rebuild turn_context from v_model_context, then assemble messages.
28
+ * Called at turn start and again after any fidelity demotion within the turn.
29
+ */
30
+ async #materializeTurnContext({
31
+ runId,
32
+ loopId,
33
+ turn,
34
+ systemPrompt,
35
+ mode,
36
+ toolSet,
37
+ contextSize,
38
+ demoted,
39
+ }) {
40
+ await this.#db.clear_turn_context.run({ run_id: runId, turn });
41
+ const viewRows = await this.#db.get_model_context.all({ run_id: runId });
42
+ for (const row of viewRows) {
43
+ const scheme = row.scheme || "file";
44
+ const projectedBody = await this.#hooks.tools.view(scheme, {
45
+ path: row.path,
46
+ scheme,
47
+ body: row.body,
48
+ attributes: row.attributes ? JSON.parse(row.attributes) : null,
49
+ fidelity: row.fidelity,
50
+ category: row.category,
51
+ });
52
+ await this.#db.insert_turn_context.run({
53
+ run_id: runId,
54
+ loop_id: loopId,
55
+ turn,
56
+ ordinal: row.ordinal,
57
+ path: row.path,
58
+ fidelity: row.fidelity,
59
+ status: row.status,
60
+ body: projectedBody ?? "",
61
+ tokens: countTokens(projectedBody ?? ""),
62
+ attributes: row.attributes,
63
+ category: row.category,
64
+ source_turn: row.turn,
65
+ });
66
+ }
67
+ const rows = await this.#db.get_turn_context.all({ run_id: runId, turn });
68
+ const lastCtx = await this.#db.get_last_context_tokens.get({
69
+ run_id: runId,
70
+ });
71
+ const lastContextTokens = lastCtx?.context_tokens ?? 0;
72
+ const messages = await ContextAssembler.assembleFromTurnContext(
73
+ rows,
74
+ {
75
+ type: mode,
76
+ systemPrompt,
77
+ contextSize,
78
+ demoted,
79
+ toolSet,
80
+ lastContextTokens,
81
+ turn,
82
+ },
83
+ this.#hooks,
84
+ );
85
+ return { rows, messages, lastContextTokens };
86
+ }
87
+
22
88
  async execute({
23
89
  mode,
24
90
  project,
@@ -28,12 +94,25 @@ export default class TurnExecutor {
28
94
  currentLoopId,
29
95
  requestedModel,
30
96
  loopPrompt,
97
+ loopIteration,
31
98
  noRepo,
32
99
  toolSet,
100
+ inRecovery = false,
33
101
  contextSize,
34
102
  options,
35
103
  signal,
36
104
  }) {
105
+ const RECOVERY_EXCLUDED = new Set([
106
+ "sh",
107
+ "env",
108
+ "search",
109
+ "ask_user",
110
+ "set",
111
+ ]);
112
+ const effectiveToolSet = inRecovery
113
+ ? new Set([...toolSet].filter((t) => !RECOVERY_EXCLUDED.has(t)))
114
+ : toolSet;
115
+
37
116
  const turn = await this.#knownStore.nextTurn(currentRunId);
38
117
 
39
118
  const turnRow = await this.#db.create_turn.get({
@@ -42,13 +121,6 @@ export default class TurnExecutor {
42
121
  sequence: turn,
43
122
  });
44
123
 
45
- const unresolved = await this.#knownStore.getUnresolved(currentRunId);
46
- if (unresolved.length > 0) {
47
- throw new Error(
48
- msg("error.unresolved_proposed", { count: unresolved.length }),
49
- );
50
- }
51
-
52
124
  // Build RummyContext before turn.started so plugins can write entries
53
125
  const rummy = new RummyContext(
54
126
  {
@@ -73,7 +145,7 @@ export default class TurnExecutor {
73
145
  loopId: currentLoopId,
74
146
  turnId: turnRow.id,
75
147
  noRepo,
76
- toolSet,
148
+ toolSet: effectiveToolSet,
77
149
  contextSize,
78
150
  systemPrompt: null,
79
151
  loopPrompt,
@@ -85,6 +157,7 @@ export default class TurnExecutor {
85
157
  mode,
86
158
  prompt: loopPrompt,
87
159
  isContinuation: options?.isContinuation,
160
+ loopIteration,
88
161
  });
89
162
 
90
163
  await this.#hooks.processTurn(rummy);
@@ -111,43 +184,23 @@ export default class TurnExecutor {
111
184
  });
112
185
 
113
186
  // Materialize turn_context: VIEW rows projected through tools
114
- await this.#db.clear_turn_context.run({ run_id: currentRunId, turn });
115
- const viewRows = await this.#db.get_model_context.all({
116
- run_id: currentRunId,
117
- });
118
- for (const row of viewRows) {
119
- const scheme = row.scheme || "file";
120
- const projectedBody = await this.#hooks.tools.view(scheme, {
121
- path: row.path,
122
- scheme,
123
- body: row.body,
124
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
125
- fidelity: row.fidelity,
126
- category: row.category,
127
- });
128
-
129
- await this.#db.insert_turn_context.run({
130
- run_id: currentRunId,
131
- loop_id: currentLoopId,
187
+ const demoted = [];
188
+ let { rows, messages, lastContextTokens } =
189
+ await this.#materializeTurnContext({
190
+ runId: currentRunId,
191
+ loopId: currentLoopId,
132
192
  turn,
133
- ordinal: row.ordinal,
134
- path: row.path,
135
- fidelity: row.fidelity,
136
- status: row.status,
137
- body: projectedBody ?? "",
138
- tokens: countTokens(projectedBody ?? ""),
139
- attributes: row.attributes,
140
- category: row.category,
141
- source_turn: row.turn,
193
+ systemPrompt,
194
+ mode,
195
+ toolSet: effectiveToolSet,
196
+ contextSize,
197
+ demoted,
142
198
  });
143
- }
144
-
145
- const demoted = [];
146
199
 
147
200
  await this.#hooks.context.materialized.emit({
148
201
  runId: currentRunId,
149
202
  turn,
150
- rowCount: viewRows.length,
203
+ rowCount: rows.length,
151
204
  });
152
205
 
153
206
  await this.#hooks.run.progress.emit({
@@ -157,50 +210,75 @@ export default class TurnExecutor {
157
210
  status: "thinking",
158
211
  });
159
212
 
160
- let rows = await this.#db.get_turn_context.all({
161
- run_id: currentRunId,
162
- turn,
163
- });
164
- const lastCtx = await this.#db.get_last_context_tokens.get({
165
- run_id: currentRunId,
166
- });
167
- const lastContextTokens = lastCtx?.context_tokens ?? 0;
168
-
169
- let messages = await ContextAssembler.assembleFromTurnContext(
170
- rows,
171
- {
172
- type: mode,
173
- systemPrompt,
174
- contextSize,
175
- demoted,
176
- toolSet,
177
- lastContextTokens,
178
- },
179
- this.#hooks,
180
- );
181
-
182
213
  const budgetResult = await this.#hooks.budget.enforce({
183
214
  contextSize,
184
215
  messages,
185
216
  rows,
217
+ lastPromptTokens: lastContextTokens,
186
218
  });
187
219
  messages = budgetResult.messages;
188
220
  rows = budgetResult.rows;
189
- const assembledTokens =
221
+ let assembledTokens =
190
222
  budgetResult.assembledTokens ??
191
223
  messages.reduce((sum, m) => sum + countTokens(m.content), 0);
192
224
 
193
- // Budget overflow — return 413 to caller without calling LLM.
194
- // Panic mode suppresses this — the model must run to free space.
195
- if (budgetResult.status === 413 && mode !== "panic") {
196
- return {
197
- turn,
198
- turnId: turnRow.id,
199
- status: 413,
200
- assembledTokens,
201
- contextSize,
202
- overflow: budgetResult.overflow,
203
- };
225
+ if (budgetResult.status === 413) {
226
+ if (loopIteration === 1) {
227
+ // Prompt Demotion: first-turn overflow demote incoming prompt to summary
228
+ const promptRow = rows.findLast(
229
+ (r) => r.category === "prompt" && r.scheme === "prompt",
230
+ );
231
+ if (promptRow) {
232
+ await this.#knownStore.setFidelity(
233
+ currentRunId,
234
+ promptRow.path,
235
+ "summary",
236
+ );
237
+ }
238
+ const reMat = await this.#materializeTurnContext({
239
+ runId: currentRunId,
240
+ loopId: currentLoopId,
241
+ turn,
242
+ systemPrompt,
243
+ mode,
244
+ toolSet: effectiveToolSet,
245
+ contextSize,
246
+ demoted,
247
+ });
248
+ rows = reMat.rows;
249
+ messages = reMat.messages;
250
+ const recheck = await this.#hooks.budget.enforce({
251
+ contextSize,
252
+ messages,
253
+ rows,
254
+ lastPromptTokens: reMat.lastContextTokens,
255
+ });
256
+ messages = recheck.messages;
257
+ rows = recheck.rows;
258
+ assembledTokens =
259
+ recheck.assembledTokens ??
260
+ messages.reduce((sum, m) => sum + countTokens(m.content), 0);
261
+ if (recheck.status === 413) {
262
+ return {
263
+ turn,
264
+ turnId: turnRow.id,
265
+ status: 413,
266
+ assembledTokens,
267
+ contextSize,
268
+ overflow: recheck.overflow,
269
+ };
270
+ }
271
+ } else {
272
+ // Base context too large even without new prompt — genuine failure
273
+ return {
274
+ turn,
275
+ turnId: turnRow.id,
276
+ status: 413,
277
+ assembledTokens,
278
+ contextSize,
279
+ overflow: budgetResult.overflow,
280
+ };
281
+ }
204
282
  }
205
283
 
206
284
  const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
@@ -216,6 +294,10 @@ export default class TurnExecutor {
216
294
  /\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
217
295
  e.message,
218
296
  );
297
+ const isContextExceeded = (e) =>
298
+ /\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
299
+ e.message,
300
+ );
219
301
 
220
302
  for (let llmAttempt = 0; ; llmAttempt++) {
221
303
  try {
@@ -234,6 +316,18 @@ export default class TurnExecutor {
234
316
  await new Promise((r) => setTimeout(r, delay));
235
317
  continue;
236
318
  }
319
+ if (isContextExceeded(err)) {
320
+ console.warn(
321
+ `[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
322
+ );
323
+ return {
324
+ turn,
325
+ turnId: turnRow.id,
326
+ status: 413,
327
+ assembledTokens,
328
+ contextSize,
329
+ };
330
+ }
237
331
  throw err;
238
332
  }
239
333
  }
@@ -290,15 +384,7 @@ export default class TurnExecutor {
290
384
  });
291
385
 
292
386
  // --- PHASE 1: RECORD ---
293
- // Split lifecycle signals from action commands.
294
- // Lifecycle signals (summarize, update, unknown, known) are state
295
- // declarations — always recorded, never 409'd by sequential dispatch.
296
- const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
297
-
298
387
  const recorded = [];
299
- const lifecycle = [];
300
- const actions = [];
301
-
302
388
  for (const cmd of commands) {
303
389
  const entry = await this.#record(
304
390
  currentRunId,
@@ -307,125 +393,114 @@ export default class TurnExecutor {
307
393
  mode,
308
394
  cmd,
309
395
  );
310
- if (!entry) continue;
311
- recorded.push(entry);
312
-
313
- if (LIFECYCLE.has(entry.scheme)) {
314
- lifecycle.push(entry);
315
- } else {
316
- actions.push(entry);
317
- }
396
+ if (entry) recorded.push(entry);
318
397
  }
319
398
 
320
399
  // --- PHASE 2: DISPATCH ---
321
- // Budget plugin activates the guard on the store for dispatch.
322
- const guard = this.#hooks.budget.activate(
323
- this.#knownStore,
324
- contextSize,
325
- assembledTokens,
326
- );
327
- const { BudgetExceeded } = this.#hooks.budget;
328
-
400
+ // Sequential queue. Each tool completes before the next starts.
401
+ // On failure: abort remaining. On proposal: notify client, await
402
+ // resolution, continue.
329
403
  let hasErrors = false;
330
- let hasProposed = false;
331
404
  let abortAfter = null;
332
- const dispatched = [...lifecycle];
333
-
334
- try {
335
- // Lifecycle signals firstalways dispatched, never aborted.
336
- for (const entry of lifecycle) {
337
- await this.#hooks.tool.before.emit({ entry, rummy });
338
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
339
- await this.#hooks.tool.after.emit({ entry, rummy });
340
- await this.#hooks.entry.created.emit(entry);
405
+
406
+ for (const entry of recorded) {
407
+ if (abortAfter) {
408
+ const errorMsg = `Abortedpreceding <${abortAfter}> failed.`;
409
+ await this.#knownStore.upsert(
410
+ currentRunId,
411
+ turn,
412
+ entry.resultPath || entry.path,
413
+ errorMsg,
414
+ 409,
415
+ { attributes: { error: errorMsg }, loopId: currentLoopId },
416
+ );
417
+ hasErrors = true;
418
+ continue;
341
419
  }
342
420
 
343
- for (const entry of actions) {
344
- if (abortAfter || guard.isTripped) {
345
- await this.#knownStore.upsert(
346
- currentRunId,
347
- turn,
348
- entry.resultPath || entry.path,
349
- "",
350
- guard.isTripped ? 413 : 409,
351
- {
352
- attributes: {
353
- error: guard.isTripped
354
- ? `Budget exceeded by <${guard.tripSource}>.`
355
- : `Aborted — preceding <${abortAfter}> requires resolution.`,
356
- },
357
- loopId: currentLoopId,
358
- },
359
- );
421
+ await this.#hooks.tool.before.emit({ entry, rummy });
422
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
423
+ await this.#hooks.tool.after.emit({ entry, rummy });
424
+ await this.#hooks.entry.created.emit(entry);
425
+
426
+ // Materialize proposals for this entry (set revisions → 202)
427
+ await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
428
+
429
+ // Check for any proposals created by this entry's dispatch
430
+ const proposed = await this.#knownStore.getUnresolved(currentRunId);
431
+ for (const p of proposed) {
432
+ await this.#hooks.turn.proposal.emit({
433
+ projectId,
434
+ run: currentAlias,
435
+ proposed: [p],
436
+ });
437
+ await this.#knownStore.waitForResolution(currentRunId, p.path);
438
+ const resolved = await this.#db.get_entry_state.get({
439
+ run_id: currentRunId,
440
+ path: p.path,
441
+ });
442
+ if (resolved?.status >= 400) {
360
443
  hasErrors = true;
361
- continue;
362
- }
363
-
364
- try {
365
- await this.#hooks.tool.before.emit({ entry, rummy });
366
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
367
- await this.#hooks.tool.after.emit({ entry, rummy });
368
- await this.#hooks.entry.created.emit(entry);
369
- dispatched.push(entry);
370
- } catch (err) {
371
- if (err instanceof BudgetExceeded) {
372
- guard.trip(entry.scheme);
373
- await this.#knownStore.upsert(
374
- currentRunId,
375
- turn,
376
- entry.resultPath || entry.path,
377
- `Budget exceeded: ${err.requested} tokens requested, ${err.remaining} remaining.`,
378
- 413,
379
- { attributes: { error: err.message }, loopId: currentLoopId },
380
- );
381
- hasErrors = true;
382
- abortAfter = entry.scheme;
383
- continue;
384
- }
385
- throw err;
444
+ abortAfter = entry.scheme;
386
445
  }
446
+ }
387
447
 
448
+ // Also check the entry itself for direct failures
449
+ if (!hasErrors) {
450
+ const entryPath = entry.resultPath || entry.path;
388
451
  const row = await this.#db.get_entry_state.get({
389
452
  run_id: currentRunId,
390
- path: entry.resultPath || entry.path,
453
+ path: entryPath,
391
454
  });
392
- if (row?.status === 202) {
393
- hasProposed = true;
394
- abortAfter = entry.scheme;
395
- } else if (row?.status >= 400) {
455
+ if (row?.status >= 400) {
396
456
  hasErrors = true;
397
457
  abortAfter = entry.scheme;
398
458
  }
399
459
  }
460
+ }
400
461
 
401
- // Materialize proposals only if we dispatched actions
402
- if (!abortAfter || hasProposed) {
403
- await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
404
- }
405
-
406
- // Recheck after materialization (set handler may create proposals)
407
- if (!hasProposed && !hasErrors) {
408
- for (const entry of actions) {
409
- const row = await this.#db.get_entry_state.get({
410
- run_id: currentRunId,
411
- path: entry.resultPath || entry.path,
412
- });
413
- if (row?.status === 202) hasProposed = true;
414
- if (row?.status >= 400) hasErrors = true;
415
- }
416
- }
417
- } finally {
418
- this.#hooks.budget.deactivate(this.#knownStore);
462
+ // Turn Demotion: if end-of-turn context exceeds ceiling, demote this
463
+ // turn's data entries and the incoming prompt to summary, then force a
464
+ // budget recovery phase before continuing.
465
+ let budgetRecovery = null;
466
+ // Use actual prompt_tokens from this turn's LLM response as the ground-truth
467
+ // Post-dispatch budget check demotion handled by budget plugin
468
+ if (contextSize) {
469
+ const postMat = await this.#materializeTurnContext({
470
+ runId: currentRunId,
471
+ loopId: currentLoopId,
472
+ turn,
473
+ systemPrompt,
474
+ mode,
475
+ toolSet: effectiveToolSet,
476
+ contextSize,
477
+ demoted,
478
+ });
479
+ budgetRecovery = await this.#hooks.budget.postDispatch({
480
+ contextSize,
481
+ messages: postMat.messages,
482
+ rows: postMat.rows,
483
+ runId: currentRunId,
484
+ loopId: currentLoopId,
485
+ turn,
486
+ db: this.#db,
487
+ store: this.#knownStore,
488
+ });
419
489
  }
420
490
 
421
- // Lifecycle signals are always available never 409'd.
422
- const summaryEntry = lifecycle.find((e) => e.scheme === "summarize");
423
- const updateEntry = lifecycle.find((e) => e.scheme === "update");
491
+ const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
492
+ const updateEntry = recorded.findLast((e) => e.scheme === "update");
424
493
  let summaryText = summaryEntry?.body || null;
425
494
  let updateText = updateEntry?.body || null;
426
495
 
427
- // If model sent both, update wins — if it can't decide, it's not done
428
- if (summaryText && updateText) summaryText = null;
496
+ // If model sent both, last signal wins — respects the model's final intent
497
+ if (summaryText && updateText) {
498
+ const lastLifecycle = recorded.findLast(
499
+ (e) => e.scheme === "summarize" || e.scheme === "update",
500
+ );
501
+ if (lastLifecycle.scheme === "summarize") updateText = null;
502
+ else summaryText = null;
503
+ }
429
504
 
430
505
  // If model says "done" but actions failed, override — the model's
431
506
  // assertion that it's done is false if it failed to do what it tried.
@@ -433,6 +508,15 @@ export default class TurnExecutor {
433
508
  console.warn(
434
509
  "[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
435
510
  );
511
+ // Mark the recorded summarize entry as 409 so the model sees it was rejected
512
+ if (summaryEntry?.path) {
513
+ await this.#knownStore.resolve(
514
+ currentRunId,
515
+ summaryEntry.path,
516
+ 409,
517
+ "Overridden — actions in this turn failed. Use <update/> until resolved.",
518
+ );
519
+ }
436
520
  updateText = summaryText;
437
521
  summaryText = null;
438
522
  }
@@ -448,11 +532,7 @@ export default class TurnExecutor {
448
532
 
449
533
  // --- Classify for return value ---
450
534
 
451
- const actionCalls = recorded.filter((e) =>
452
- ["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
453
- e.scheme,
454
- ),
455
- );
535
+ const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
456
536
  const writeCalls = recorded.filter(
457
537
  (e) =>
458
538
  e.scheme === "known" ||
@@ -460,12 +540,8 @@ export default class TurnExecutor {
460
540
  );
461
541
  const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
462
542
 
463
- const hasAct = actionCalls.some((c) =>
464
- ["set", "rm", "sh", "mv", "cp"].includes(c.scheme),
465
- );
466
- const hasReads = actionCalls.some((c) =>
467
- ["get", "env", "search"].includes(c.scheme),
468
- );
543
+ const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
544
+ const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
469
545
  const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
470
546
  const flags = { hasAct, hasReads, hasWrites };
471
547
 
@@ -484,12 +560,11 @@ export default class TurnExecutor {
484
560
  flags,
485
561
  model: result.model || requestedModel,
486
562
  modelAlias: requestedModel,
487
- temperature:
488
- options?.temperature ??
489
- Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
563
+ temperature: options?.temperature,
490
564
  contextSize,
491
565
  assembledTokens,
492
566
  usage: result.usage,
567
+ budgetRecovery,
493
568
  };
494
569
 
495
570
  await this.#hooks.turn.completed.emit(turnResult);
@@ -502,85 +577,33 @@ export default class TurnExecutor {
502
577
  * Returns the recorded entry descriptor, or null if rejected/skipped.
503
578
  */
504
579
  async #record(runId, loopId, turn, mode, cmd) {
505
- // Mode enforcement — reject prohibited commands in ask/panic mode
506
- if (mode === "ask" || mode === "panic") {
507
- if (cmd.name === "sh") {
508
- console.warn("[RUMMY] Rejected <sh> in ask mode");
509
- return null;
510
- }
511
- if (cmd.name === "set" && cmd.path && cmd.body) {
512
- const scheme = KnownStore.scheme(cmd.path);
513
- if (scheme === null) {
514
- console.warn(
515
- `[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
516
- );
517
- return null;
518
- }
519
- }
520
- if (cmd.name === "rm" && cmd.path) {
521
- const scheme = KnownStore.scheme(cmd.path);
522
- if (scheme === null) {
523
- console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
524
- return null;
525
- }
526
- }
527
- if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
528
- const destScheme = KnownStore.scheme(cmd.to);
529
- if (destScheme === null) {
530
- console.warn(
531
- `[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
532
- );
533
- return null;
534
- }
535
- }
536
- }
537
-
538
580
  const scheme = cmd.name;
539
-
540
- // Structural tagsrecorded like any other entry
541
- if (scheme === "summarize" || scheme === "update") {
542
- const statusPath = await this.#knownStore.slugPath(
581
+ const rawTarget = cmd.path || cmd.command || cmd.question || "";
582
+ // Reject paths that are likely reasoning bleed too long or contain non-printing chars
583
+ if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
584
+ const rejectPath = await this.#knownStore.dedup(
543
585
  runId,
544
586
  scheme,
545
- cmd.body,
587
+ `${scheme}://invalid`,
588
+ turn,
546
589
  );
547
- await this.#knownStore.upsert(runId, turn, statusPath, cmd.body, 200, {
548
- loopId,
549
- });
550
- return {
551
- scheme,
552
- body: cmd.body,
553
- path: statusPath,
554
- resultPath: statusPath,
555
- attributes: null,
556
- };
557
- }
558
-
559
- // Unknown — deduplicated, sticky
560
- if (scheme === "unknown") {
561
- const existingValues = await this.#knownStore.getUnknownValues(runId);
562
- if (existingValues.has(cmd.body)) {
563
- console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
564
- return null;
565
- }
566
- const unknownPath = await this.#knownStore.slugPath(
590
+ await this.#knownStore.upsert(
567
591
  runId,
568
- "unknown",
569
- cmd.body,
592
+ turn,
593
+ rejectPath,
594
+ `Invalid path: too long or contains non-printing characters`,
595
+ 400,
596
+ { loopId },
570
597
  );
571
- await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
572
- loopId,
573
- });
574
598
  return {
575
599
  scheme,
576
- path: unknownPath,
577
- body: cmd.body,
578
- resultPath: unknownPath,
579
- attributes: null,
600
+ path: rejectPath,
601
+ body: "",
602
+ attributes: {},
603
+ status: 400,
604
+ resultPath: rejectPath,
580
605
  };
581
606
  }
582
-
583
- const rawTarget = cmd.path || cmd.command || cmd.question || "";
584
607
  const target = rawTarget;
585
608
  const resultPath = await this.#knownStore.dedup(
586
609
  runId,
@@ -593,106 +616,15 @@ export default class TurnExecutor {
593
616
  const { name: _, ...attributes } = cmd;
594
617
  if (cmd.path) attributes.path = target;
595
618
 
596
- // known tool or naked write → known:// slug from body
597
- if (scheme === "known" || (scheme === "set" && !cmd.path)) {
598
- if (!cmd.body) return null;
599
-
600
- // Size gate: reject entries > 512 tokens — force atomic entries
601
- const entryTokens = countTokens(cmd.body);
602
- const MAX_ENTRY_TOKENS = 512;
603
- if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
604
- const rejectPath = await this.#knownStore.slugPath(
605
- runId,
606
- scheme,
607
- cmd.body,
608
- );
609
- await this.#knownStore.upsert(
610
- runId,
611
- turn,
612
- rejectPath,
613
- `Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
614
- 413,
615
- { loopId },
616
- );
617
- return {
618
- scheme,
619
- path: rejectPath,
620
- body: "",
621
- resultPath: rejectPath,
622
- attributes,
623
- status: 413,
624
- };
625
- }
626
-
627
- let knownPath = cmd.path;
628
- if (!knownPath) {
629
- knownPath = await this.#knownStore.slugPath(
630
- runId,
631
- "known",
632
- cmd.body,
633
- cmd.summary,
634
- );
635
- }
636
- // Dedup: if this exact path already exists, update rather than duplicate
637
- const existing = await this.#knownStore.getEntriesByPattern(
638
- runId,
639
- knownPath,
640
- null,
641
- );
642
- if (existing.length > 0) {
643
- // Path exists — update body and turn, skip creating a new entry
644
- await this.#knownStore.upsert(
645
- runId,
646
- turn,
647
- existing[0].path,
648
- cmd.body || existing[0].body,
649
- 200,
650
- {
651
- loopId,
652
- },
653
- );
654
- return {
655
- scheme: "known",
656
- path: existing[0].path,
657
- body: cmd.body || existing[0].body,
658
- resultPath: existing[0].path,
659
- attributes,
660
- };
661
- }
662
- await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
663
- loopId,
664
- });
665
- return {
666
- scheme: "known",
667
- path: knownPath,
668
- body: cmd.body,
669
- resultPath: knownPath,
670
- attributes,
671
- };
672
- }
673
-
674
619
  const body = cmd.body || cmd.command || cmd.question || "";
675
620
 
676
621
  // Filter: plugins can validate/transform before recording
677
622
  const filtered = await this.#hooks.entry.recording.filter(
678
623
  { scheme, path: resultPath, body, attributes, status: 200 },
679
- { runId, turn, loopId },
624
+ { runId, turn, loopId, mode },
680
625
  );
681
626
  if (filtered.status >= 400) return filtered;
682
627
 
683
- // Record the entry — 200 OK, handlers change status during dispatch
684
- await this.#knownStore.upsert(
685
- runId,
686
- turn,
687
- filtered.path,
688
- filtered.body,
689
- 200,
690
- {
691
- attributes: filtered.attributes,
692
- loopId,
693
- },
694
- );
695
-
696
628
  return {
697
629
  scheme: filtered.scheme,
698
630
  path: filtered.path,
@@ -702,34 +634,4 @@ export default class TurnExecutor {
702
634
  resultPath: filtered.path,
703
635
  };
704
636
  }
705
-
706
- async #rematerialize(runId, loopId, turn) {
707
- await this.#db.clear_turn_context.run({ run_id: runId, turn });
708
- const viewRows = await this.#db.get_model_context.all({ run_id: runId });
709
- for (const row of viewRows) {
710
- const scheme = row.scheme || "file";
711
- const projectedBody = await this.#hooks.tools.view(scheme, {
712
- path: row.path,
713
- scheme,
714
- body: row.body,
715
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
716
- fidelity: row.fidelity,
717
- category: row.category,
718
- });
719
- await this.#db.insert_turn_context.run({
720
- run_id: runId,
721
- loop_id: loopId,
722
- turn,
723
- ordinal: row.ordinal,
724
- path: row.path,
725
- fidelity: row.fidelity,
726
- status: row.status,
727
- body: projectedBody ?? "",
728
- tokens: countTokens(projectedBody ?? ""),
729
- attributes: row.attributes,
730
- category: row.category,
731
- source_turn: row.turn,
732
- });
733
- }
734
- }
735
637
  }