@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
@@ -28,7 +28,8 @@ export default class TurnExecutor {
28
28
  currentLoopId,
29
29
  requestedModel,
30
30
  loopPrompt,
31
- noContext,
31
+ noRepo,
32
+ toolSet,
32
33
  contextSize,
33
34
  options,
34
35
  signal,
@@ -71,7 +72,8 @@ export default class TurnExecutor {
71
72
  runId: currentRunId,
72
73
  loopId: currentLoopId,
73
74
  turnId: turnRow.id,
74
- noContext,
75
+ noRepo,
76
+ toolSet,
75
77
  contextSize,
76
78
  systemPrompt: null,
77
79
  loopPrompt,
@@ -142,6 +144,12 @@ export default class TurnExecutor {
142
144
 
143
145
  const demoted = [];
144
146
 
147
+ await this.#hooks.context.materialized.emit({
148
+ runId: currentRunId,
149
+ turn,
150
+ rowCount: viewRows.length,
151
+ });
152
+
145
153
  await this.#hooks.run.progress.emit({
146
154
  projectId,
147
155
  run: currentAlias,
@@ -149,11 +157,15 @@ export default class TurnExecutor {
149
157
  status: "thinking",
150
158
  });
151
159
 
152
- // Assemble messages from projected system prompt + materialized turn_context
153
160
  let rows = await this.#db.get_turn_context.all({
154
161
  run_id: currentRunId,
155
162
  turn,
156
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
+
157
169
  let messages = await ContextAssembler.assembleFromTurnContext(
158
170
  rows,
159
171
  {
@@ -161,83 +173,34 @@ export default class TurnExecutor {
161
173
  systemPrompt,
162
174
  contextSize,
163
175
  demoted,
176
+ toolSet,
177
+ lastContextTokens,
164
178
  },
165
179
  this.#hooks,
166
180
  );
167
181
 
168
- // Budget check on assembled messages (includes system prompt)
169
- if (contextSize && demoted.length === 0) {
170
- const assembledTokens = messages.reduce(
171
- (sum, m) => sum + countTokens(m.content || ""),
172
- 0,
173
- );
174
- const ceiling = contextSize * 0.95;
175
- if (assembledTokens > ceiling) {
176
- const candidates = rows
177
- .filter(
178
- (r) =>
179
- r.fidelity === "full" &&
180
- r.tokens > 0 &&
181
- (r.category === "file" || r.category === "known"),
182
- )
183
- .toSorted((a, b) => a.source_turn - b.source_turn);
184
-
185
- let excess = assembledTokens - ceiling;
186
- for (const entry of candidates) {
187
- if (excess <= 0) break;
188
- await this.#knownStore.setFidelity(
189
- currentRunId,
190
- entry.path,
191
- "summary",
192
- );
193
- excess -= entry.tokens;
194
- demoted.push(entry.path);
195
- }
196
-
197
- if (demoted.length > 0) {
198
- await this.#db.clear_turn_context.run({ run_id: currentRunId, turn });
199
- const freshViewRows = await this.#db.get_model_context.all({
200
- run_id: currentRunId,
201
- });
202
- for (const row of freshViewRows) {
203
- const scheme = row.scheme || "file";
204
- const projectedBody = await this.#hooks.tools.view(scheme, {
205
- path: row.path,
206
- scheme,
207
- body: row.body,
208
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
209
- fidelity: row.fidelity,
210
- category: row.category,
211
- });
212
- await this.#db.insert_turn_context.run({
213
- run_id: currentRunId,
214
- loop_id: currentLoopId,
215
- turn,
216
- ordinal: row.ordinal,
217
- path: row.path,
218
- fidelity: row.fidelity,
219
- status: row.status,
220
- body: projectedBody ?? "",
221
- tokens: countTokens(projectedBody ?? ""),
222
- attributes: row.attributes,
223
- category: row.category,
224
- source_turn: row.turn,
225
- });
226
- }
227
- rows = await this.#db.get_turn_context.all({
228
- run_id: currentRunId,
229
- turn,
230
- });
231
- messages = await ContextAssembler.assembleFromTurnContext(
232
- rows,
233
- { type: mode, systemPrompt, contextSize, demoted },
234
- this.#hooks,
235
- );
236
- console.warn(
237
- `[RUMMY] Budget exceeded: demoted ${demoted.length} entries to fit ${contextSize} token limit`,
238
- );
239
- }
240
- }
182
+ const budgetResult = await this.#hooks.budget.enforce({
183
+ contextSize,
184
+ messages,
185
+ rows,
186
+ });
187
+ messages = budgetResult.messages;
188
+ rows = budgetResult.rows;
189
+ const assembledTokens =
190
+ budgetResult.assembledTokens ??
191
+ messages.reduce((sum, m) => sum + countTokens(m.content), 0);
192
+
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
+ };
241
204
  }
242
205
 
243
206
  const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
@@ -246,14 +209,34 @@ export default class TurnExecutor {
246
209
  runId: currentRunId,
247
210
  });
248
211
 
249
- // Store assembled messages as audit
250
212
  // Call LLM
251
213
  await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
252
- const rawResult = await this.#llmProvider.completion(
253
- filteredMessages,
254
- requestedModel,
255
- { temperature: options?.temperature, signal },
256
- );
214
+ let rawResult;
215
+ const isTransient = (e) =>
216
+ /\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
217
+ e.message,
218
+ );
219
+
220
+ for (let llmAttempt = 0; ; llmAttempt++) {
221
+ try {
222
+ rawResult = await this.#llmProvider.completion(
223
+ filteredMessages,
224
+ requestedModel,
225
+ { temperature: options?.temperature, signal },
226
+ );
227
+ break;
228
+ } catch (err) {
229
+ if (isTransient(err) && llmAttempt < 3) {
230
+ const delay = 1000 * 2 ** llmAttempt;
231
+ console.warn(
232
+ `[RUMMY] Transient LLM error (attempt ${llmAttempt + 1}/3): ${err.message.slice(0, 120)}. Retrying in ${delay}ms.`,
233
+ );
234
+ await new Promise((r) => setTimeout(r, delay));
235
+ continue;
236
+ }
237
+ throw err;
238
+ }
239
+ }
257
240
  const result = await this.#hooks.llm.response.filter(rawResult, {
258
241
  model: requestedModel,
259
242
  projectId,
@@ -277,6 +260,19 @@ export default class TurnExecutor {
277
260
  // Parse and emit — plugins handle audit storage
278
261
  const { commands, unparsed } = XmlParser.parse(content);
279
262
 
263
+ // Ensure reasoning_content captures both API field and <think> tag
264
+ if (responseMessage) {
265
+ const thinkCmds = commands.filter((c) => c.name === "think");
266
+ const thinkText = thinkCmds
267
+ .map((c) => c.body)
268
+ .filter(Boolean)
269
+ .join("\n");
270
+ const apiReasoning = responseMessage.reasoning_content || "";
271
+ const parts = [apiReasoning, thinkText].filter(Boolean);
272
+ responseMessage.reasoning_content =
273
+ parts.length > 0 ? parts.join("\n") : null;
274
+ }
275
+
280
276
  const systemMsg = filteredMessages.find((m) => m.role === "system");
281
277
  const userMsg = filteredMessages.find((m) => m.role === "user");
282
278
  await this.#hooks.turn.response.emit({
@@ -287,16 +283,21 @@ export default class TurnExecutor {
287
283
  content,
288
284
  commands,
289
285
  unparsed,
286
+ assembledTokens,
287
+ contextSize,
290
288
  systemMsg: systemMsg?.content,
291
289
  userMsg: userMsg?.content,
292
290
  });
293
291
 
294
292
  // --- PHASE 1: RECORD ---
295
- // Every command becomes an entry. No execution yet.
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"]);
296
297
 
297
298
  const recorded = [];
298
- let summaryText = null;
299
- let updateText = null;
299
+ const lifecycle = [];
300
+ const actions = [];
300
301
 
301
302
  for (const cmd of commands) {
302
303
  const entry = await this.#record(
@@ -307,94 +308,148 @@ export default class TurnExecutor {
307
308
  cmd,
308
309
  );
309
310
  if (!entry) continue;
311
+ recorded.push(entry);
310
312
 
311
- if (entry.scheme === "summarize") summaryText = entry.body;
312
- else if (entry.scheme === "update") updateText = entry.body;
313
- else recorded.push(entry);
313
+ if (LIFECYCLE.has(entry.scheme)) {
314
+ lifecycle.push(entry);
315
+ } else {
316
+ actions.push(entry);
317
+ }
314
318
  }
315
319
 
316
- // If model sent both, summary wins
317
- if (summaryText && updateText) updateText = null;
320
+ // --- 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;
318
328
 
319
- // If model sent neither, heal from content
320
- let statusHealed = false;
321
- if (!summaryText && !updateText) {
322
- const healed = ResponseHealer.healStatus(content, commands);
323
- summaryText = healed.summaryText;
324
- updateText = healed.updateText;
325
- statusHealed = true;
326
- }
329
+ let hasErrors = false;
330
+ let hasProposed = false;
331
+ let abortAfter = null;
332
+ const dispatched = [...lifecycle];
333
+
334
+ try {
335
+ // Lifecycle signals first — always 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);
341
+ }
327
342
 
328
- // Record healed status
329
- if (summaryText) {
330
- const summaryPath = await this.#knownStore.slugPath(
331
- currentRunId,
332
- "summarize",
333
- summaryText,
334
- );
335
- await this.#knownStore.upsert(
336
- currentRunId,
337
- turn,
338
- summaryPath,
339
- summaryText,
340
- 200,
341
- { loopId: currentLoopId },
342
- );
343
- } else if (updateText) {
344
- const updatePath = await this.#knownStore.slugPath(
345
- currentRunId,
346
- "update",
347
- updateText,
348
- );
349
- await this.#knownStore.upsert(
350
- currentRunId,
351
- turn,
352
- updatePath,
353
- updateText,
354
- 200,
355
- { loopId: currentLoopId },
356
- );
357
- }
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
+ );
360
+ hasErrors = true;
361
+ continue;
362
+ }
358
363
 
359
- // --- PHASE 2: DISPATCH ---
360
- // Handlers perform side effects: promote, demote, patch, propose.
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;
386
+ }
361
387
 
362
- for (const entry of recorded) {
363
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
364
- await this.#hooks.entry.created.emit(entry);
365
- }
388
+ const row = await this.#db.get_entry_state.get({
389
+ run_id: currentRunId,
390
+ path: entry.resultPath || entry.path,
391
+ });
392
+ if (row?.status === 202) {
393
+ hasProposed = true;
394
+ abortAfter = entry.scheme;
395
+ } else if (row?.status >= 400) {
396
+ hasErrors = true;
397
+ abortAfter = entry.scheme;
398
+ }
399
+ }
366
400
 
367
- // Materialize proposals (e.g. file plugin applies accumulated revisions)
368
- await this.#hooks.turn.proposing.emit({ rummy, recorded });
401
+ // Materialize proposals only if we dispatched actions
402
+ if (!abortAfter || hasProposed) {
403
+ await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
404
+ }
369
405
 
370
- // Check if any dispatched entries ended in error or proposed state
371
- let hasErrors = false;
372
- let hasProposed = false;
373
- for (const entry of recorded) {
374
- const row = await this.#db.get_entry_state.get({
375
- run_id: currentRunId,
376
- path: entry.resultPath || entry.path,
377
- });
378
- if (row?.status >= 400) hasErrors = true;
379
- if (row?.status === 202) hasProposed = true;
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);
380
419
  }
381
420
 
382
- // Errors override summarize the model thinks it's done but it's not
383
- if (hasErrors && summaryText) {
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");
424
+ let summaryText = summaryEntry?.body || null;
425
+ let updateText = updateEntry?.body || null;
426
+
427
+ // If model sent both, update wins — if it can't decide, it's not done
428
+ if (summaryText && updateText) summaryText = null;
429
+
430
+ // If model says "done" but actions failed, override — the model's
431
+ // assertion that it's done is false if it failed to do what it tried.
432
+ if (summaryText && hasErrors) {
433
+ console.warn(
434
+ "[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
435
+ );
436
+ updateText = summaryText;
384
437
  summaryText = null;
385
- updateText = "Tool errors detected — retry or investigate.";
386
438
  }
387
439
 
388
- // Proposals override summarize outcome unknown until user resolves
389
- if (hasProposed && summaryText) {
390
- summaryText = null;
391
- updateText = "Awaiting approval for proposed changes.";
440
+ // If model sent neither, heal from content
441
+ let statusHealed = false;
442
+ if (!summaryText && !updateText) {
443
+ const healed = ResponseHealer.healStatus(content, commands);
444
+ summaryText = healed.summaryText;
445
+ updateText = healed.updateText;
446
+ statusHealed = true;
392
447
  }
393
448
 
394
449
  // --- Classify for return value ---
395
450
 
396
451
  const actionCalls = recorded.filter((e) =>
397
- ["get", "store", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
452
+ ["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
398
453
  e.scheme,
399
454
  ),
400
455
  );
@@ -416,7 +471,7 @@ export default class TurnExecutor {
416
471
 
417
472
  const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
418
473
 
419
- return {
474
+ const turnResult = {
420
475
  turn,
421
476
  turnId: turnRow.id,
422
477
  actionCalls,
@@ -433,8 +488,13 @@ export default class TurnExecutor {
433
488
  options?.temperature ??
434
489
  Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
435
490
  contextSize,
491
+ assembledTokens,
436
492
  usage: result.usage,
437
493
  };
494
+
495
+ await this.#hooks.turn.completed.emit(turnResult);
496
+
497
+ return turnResult;
438
498
  }
439
499
 
440
500
  /**
@@ -442,16 +502,18 @@ export default class TurnExecutor {
442
502
  * Returns the recorded entry descriptor, or null if rejected/skipped.
443
503
  */
444
504
  async #record(runId, loopId, turn, mode, cmd) {
445
- // Mode enforcement — reject prohibited commands in ask mode
446
- if (mode === "ask") {
505
+ // Mode enforcement — reject prohibited commands in ask/panic mode
506
+ if (mode === "ask" || mode === "panic") {
447
507
  if (cmd.name === "sh") {
448
508
  console.warn("[RUMMY] Rejected <sh> in ask mode");
449
509
  return null;
450
510
  }
451
- if (cmd.name === "set" && cmd.path) {
511
+ if (cmd.name === "set" && cmd.path && cmd.body) {
452
512
  const scheme = KnownStore.scheme(cmd.path);
453
513
  if (scheme === null) {
454
- console.warn(`[RUMMY] Rejected file set to ${cmd.path} in ask mode`);
514
+ console.warn(
515
+ `[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
516
+ );
455
517
  return null;
456
518
  }
457
519
  }
@@ -475,15 +537,32 @@ export default class TurnExecutor {
475
537
 
476
538
  const scheme = cmd.name;
477
539
 
478
- // Structural tags — record and return (no handler dispatch)
540
+ // Structural tags — recorded like any other entry
479
541
  if (scheme === "summarize" || scheme === "update") {
480
- return { scheme, body: cmd.body, resultPath: null, attributes: null };
542
+ const statusPath = await this.#knownStore.slugPath(
543
+ runId,
544
+ scheme,
545
+ cmd.body,
546
+ );
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
+ };
481
557
  }
482
558
 
483
559
  // Unknown — deduplicated, sticky
484
560
  if (scheme === "unknown") {
485
561
  const existingValues = await this.#knownStore.getUnknownValues(runId);
486
- if (existingValues.has(cmd.body)) return null;
562
+ if (existingValues.has(cmd.body)) {
563
+ console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
564
+ return null;
565
+ }
487
566
  const unknownPath = await this.#knownStore.slugPath(
488
567
  runId,
489
568
  "unknown",
@@ -503,7 +582,12 @@ export default class TurnExecutor {
503
582
 
504
583
  const rawTarget = cmd.path || cmd.command || cmd.question || "";
505
584
  const target = rawTarget;
506
- const resultPath = await this.#knownStore.dedup(runId, scheme, target);
585
+ const resultPath = await this.#knownStore.dedup(
586
+ runId,
587
+ scheme,
588
+ target,
589
+ turn,
590
+ );
507
591
 
508
592
  // Pass parsed command fields through as attributes
509
593
  const { name: _, ...attributes } = cmd;
@@ -512,8 +596,69 @@ export default class TurnExecutor {
512
596
  // known tool or naked write → known:// slug from body
513
597
  if (scheme === "known" || (scheme === "set" && !cmd.path)) {
514
598
  if (!cmd.body) return null;
515
- const knownPath =
516
- cmd.path || (await this.#knownStore.slugPath(runId, "known", cmd.body));
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
+ }
517
662
  await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
518
663
  loopId,
519
664
  });
@@ -526,20 +671,65 @@ export default class TurnExecutor {
526
671
  };
527
672
  }
528
673
 
529
- // Record the entry — 200 OK, handlers change status during dispatch
530
674
  const body = cmd.body || cmd.command || cmd.question || "";
531
- await this.#knownStore.upsert(runId, turn, resultPath, body, 200, {
532
- attributes,
533
- loopId,
534
- });
675
+
676
+ // Filter: plugins can validate/transform before recording
677
+ const filtered = await this.#hooks.entry.recording.filter(
678
+ { scheme, path: resultPath, body, attributes, status: 200 },
679
+ { runId, turn, loopId },
680
+ );
681
+ if (filtered.status >= 400) return filtered;
682
+
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
+ );
535
695
 
536
696
  return {
537
- scheme,
538
- path: resultPath,
539
- body,
540
- attributes,
697
+ scheme: filtered.scheme,
698
+ path: filtered.path,
699
+ body: filtered.body,
700
+ attributes: filtered.attributes,
541
701
  status: 200,
542
- resultPath,
702
+ resultPath: filtered.path,
543
703
  };
544
704
  }
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
+ }
545
735
  }