@possumtech/rummy 0.2.7 → 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 (119) hide show
  1. package/.env.example +12 -3
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +454 -197
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +57 -70
  6. package/package.json +16 -10
  7. package/service.js +1 -1
  8. package/src/agent/AgentLoop.js +254 -70
  9. package/src/agent/ContextAssembler.js +18 -4
  10. package/src/agent/KnownStore.js +156 -23
  11. package/src/agent/ProjectAgent.js +5 -4
  12. package/src/agent/ResponseHealer.js +21 -1
  13. package/src/agent/TurnExecutor.js +393 -115
  14. package/src/agent/XmlParser.js +92 -39
  15. package/src/agent/known_checks.sql +5 -4
  16. package/src/agent/known_queries.sql +4 -3
  17. package/src/agent/known_store.sql +45 -15
  18. package/src/agent/loops.sql +63 -0
  19. package/src/agent/runs.sql +7 -7
  20. package/src/agent/schemes.sql +5 -2
  21. package/src/agent/tokens.js +6 -21
  22. package/src/agent/turns.sql +13 -4
  23. package/src/hooks/Hooks.js +18 -0
  24. package/src/hooks/PluginContext.js +14 -10
  25. package/src/hooks/RummyContext.js +30 -10
  26. package/src/hooks/ToolRegistry.js +83 -19
  27. package/src/llm/LlmProvider.js +27 -8
  28. package/src/llm/OpenAiClient.js +20 -0
  29. package/src/llm/OpenRouterClient.js +24 -2
  30. package/src/llm/XaiClient.js +47 -2
  31. package/src/plugins/ask_user/README.md +4 -4
  32. package/src/plugins/ask_user/ask_user.js +8 -7
  33. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  34. package/src/plugins/budget/BudgetGuard.js +74 -0
  35. package/src/plugins/budget/README.md +43 -0
  36. package/src/plugins/budget/budget.js +79 -0
  37. package/src/plugins/cp/README.md +5 -4
  38. package/src/plugins/cp/cp.js +16 -12
  39. package/src/plugins/cp/cpDoc.js +29 -0
  40. package/src/plugins/current/README.md +4 -4
  41. package/src/plugins/current/current.js +12 -10
  42. package/src/plugins/engine/engine.sql +5 -10
  43. package/src/plugins/engine/turn_context.sql +13 -13
  44. package/src/plugins/env/README.md +3 -4
  45. package/src/plugins/env/env.js +8 -7
  46. package/src/plugins/env/envDoc.js +29 -0
  47. package/src/plugins/file/README.md +9 -12
  48. package/src/plugins/file/file.js +34 -45
  49. package/src/plugins/get/README.md +2 -2
  50. package/src/plugins/get/get.js +28 -11
  51. package/src/plugins/get/getDoc.js +41 -0
  52. package/src/plugins/hedberg/docs.md +0 -9
  53. package/src/plugins/hedberg/hedberg.js +4 -6
  54. package/src/plugins/hedberg/matcher.js +1 -1
  55. package/src/plugins/hedberg/normalize.js +28 -0
  56. package/src/plugins/hedberg/patterns.js +31 -33
  57. package/src/plugins/hedberg/sed.js +17 -10
  58. package/src/plugins/helpers.js +2 -2
  59. package/src/plugins/index.js +93 -28
  60. package/src/plugins/instructions/README.md +6 -2
  61. package/src/plugins/instructions/instructions.js +21 -5
  62. package/src/plugins/instructions/preamble.md +9 -5
  63. package/src/plugins/known/README.md +10 -7
  64. package/src/plugins/known/known.js +33 -23
  65. package/src/plugins/known/knownDoc.js +33 -0
  66. package/src/plugins/mv/README.md +5 -4
  67. package/src/plugins/mv/mv.js +16 -12
  68. package/src/plugins/mv/mvDoc.js +31 -0
  69. package/src/plugins/persona/persona.js +78 -0
  70. package/src/plugins/previous/README.md +2 -2
  71. package/src/plugins/previous/previous.js +12 -8
  72. package/src/plugins/progress/progress.js +44 -12
  73. package/src/plugins/prompt/README.md +5 -5
  74. package/src/plugins/prompt/prompt.js +23 -19
  75. package/src/plugins/rm/README.md +4 -4
  76. package/src/plugins/rm/rm.js +29 -12
  77. package/src/plugins/rm/rmDoc.js +30 -0
  78. package/src/plugins/rpc/README.md +15 -28
  79. package/src/plugins/rpc/rpc.js +63 -107
  80. package/src/plugins/set/README.md +13 -12
  81. package/src/plugins/set/set.js +82 -21
  82. package/src/plugins/set/setDoc.js +45 -0
  83. package/src/plugins/sh/README.md +4 -4
  84. package/src/plugins/sh/sh.js +8 -7
  85. package/src/plugins/sh/shDoc.js +29 -0
  86. package/src/plugins/{skills/skills.js → skill/skill.js} +12 -54
  87. package/src/plugins/summarize/README.md +6 -5
  88. package/src/plugins/summarize/summarize.js +7 -6
  89. package/src/plugins/summarize/summarizeDoc.js +33 -0
  90. package/src/plugins/telemetry/telemetry.js +20 -8
  91. package/src/plugins/think/README.md +20 -0
  92. package/src/plugins/think/think.js +5 -0
  93. package/src/plugins/unknown/README.md +5 -5
  94. package/src/plugins/unknown/unknown.js +11 -8
  95. package/src/plugins/unknown/unknownDoc.js +31 -0
  96. package/src/plugins/update/README.md +3 -8
  97. package/src/plugins/update/update.js +7 -6
  98. package/src/plugins/update/updateDoc.js +33 -0
  99. package/src/server/ClientConnection.js +3 -5
  100. package/src/server/RpcRegistry.js +52 -4
  101. package/src/sql/v_model_context.sql +31 -39
  102. package/src/sql/v_run_log.sql +3 -3
  103. package/src/agent/prompt_queue.sql +0 -39
  104. package/src/plugins/ask_user/docs.md +0 -2
  105. package/src/plugins/cp/docs.md +0 -2
  106. package/src/plugins/env/docs.md +0 -2
  107. package/src/plugins/get/docs.md +0 -6
  108. package/src/plugins/known/docs.md +0 -3
  109. package/src/plugins/mv/docs.md +0 -2
  110. package/src/plugins/rm/docs.md +0 -4
  111. package/src/plugins/set/docs.md +0 -4
  112. package/src/plugins/sh/docs.md +0 -2
  113. package/src/plugins/skills/README.md +0 -25
  114. package/src/plugins/store/README.md +0 -20
  115. package/src/plugins/store/docs.md +0 -5
  116. package/src/plugins/store/store.js +0 -52
  117. package/src/plugins/summarize/docs.md +0 -4
  118. package/src/plugins/unknown/docs.md +0 -5
  119. package/src/plugins/update/docs.md +0 -4
@@ -25,9 +25,11 @@ export default class TurnExecutor {
25
25
  projectId,
26
26
  currentRunId,
27
27
  currentAlias,
28
+ currentLoopId,
28
29
  requestedModel,
29
30
  loopPrompt,
30
- noContext,
31
+ noRepo,
32
+ toolSet,
31
33
  contextSize,
32
34
  options,
33
35
  signal,
@@ -36,6 +38,7 @@ export default class TurnExecutor {
36
38
 
37
39
  const turnRow = await this.#db.create_turn.get({
38
40
  run_id: currentRunId,
41
+ loop_id: currentLoopId,
39
42
  sequence: turn,
40
43
  });
41
44
 
@@ -67,8 +70,10 @@ export default class TurnExecutor {
67
70
  type: mode,
68
71
  sequence: turn,
69
72
  runId: currentRunId,
73
+ loopId: currentLoopId,
70
74
  turnId: turnRow.id,
71
- noContext,
75
+ noRepo,
76
+ toolSet,
72
77
  contextSize,
73
78
  systemPrompt: null,
74
79
  loopPrompt,
@@ -123,11 +128,12 @@ export default class TurnExecutor {
123
128
 
124
129
  await this.#db.insert_turn_context.run({
125
130
  run_id: currentRunId,
131
+ loop_id: currentLoopId,
126
132
  turn,
127
133
  ordinal: row.ordinal,
128
134
  path: row.path,
129
135
  fidelity: row.fidelity,
130
- state: row.state,
136
+ status: row.status,
131
137
  body: projectedBody ?? "",
132
138
  tokens: countTokens(projectedBody ?? ""),
133
139
  attributes: row.attributes,
@@ -136,6 +142,14 @@ export default class TurnExecutor {
136
142
  });
137
143
  }
138
144
 
145
+ const demoted = [];
146
+
147
+ await this.#hooks.context.materialized.emit({
148
+ runId: currentRunId,
149
+ turn,
150
+ rowCount: viewRows.length,
151
+ });
152
+
139
153
  await this.#hooks.run.progress.emit({
140
154
  projectId,
141
155
  run: currentAlias,
@@ -143,35 +157,86 @@ export default class TurnExecutor {
143
157
  status: "thinking",
144
158
  });
145
159
 
146
- // Assemble messages from projected system prompt + materialized turn_context
147
- const rows = await this.#db.get_turn_context.all({
160
+ let rows = await this.#db.get_turn_context.all({
148
161
  run_id: currentRunId,
149
162
  turn,
150
163
  });
151
- const messages = await ContextAssembler.assembleFromTurnContext(
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(
152
170
  rows,
153
171
  {
154
172
  type: mode,
155
173
  systemPrompt,
156
174
  contextSize,
175
+ demoted,
176
+ toolSet,
177
+ lastContextTokens,
157
178
  },
158
179
  this.#hooks,
159
180
  );
160
181
 
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
+ };
204
+ }
205
+
161
206
  const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
162
207
  model: requestedModel,
163
208
  projectId,
164
209
  runId: currentRunId,
165
210
  });
166
211
 
167
- // Store assembled messages as audit
168
212
  // Call LLM
169
213
  await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
170
- const rawResult = await this.#llmProvider.completion(
171
- filteredMessages,
172
- requestedModel,
173
- { temperature: options?.temperature, signal },
174
- );
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
+ }
175
240
  const result = await this.#hooks.llm.response.filter(rawResult, {
176
241
  model: requestedModel,
177
242
  projectId,
@@ -195,6 +260,19 @@ export default class TurnExecutor {
195
260
  // Parse and emit — plugins handle audit storage
196
261
  const { commands, unparsed } = XmlParser.parse(content);
197
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
+
198
276
  const systemMsg = filteredMessages.find((m) => m.role === "system");
199
277
  const userMsg = filteredMessages.find((m) => m.role === "user");
200
278
  await this.#hooks.turn.response.emit({
@@ -205,98 +283,173 @@ export default class TurnExecutor {
205
283
  content,
206
284
  commands,
207
285
  unparsed,
286
+ assembledTokens,
287
+ contextSize,
208
288
  systemMsg: systemMsg?.content,
209
289
  userMsg: userMsg?.content,
210
290
  });
211
291
 
212
292
  // --- PHASE 1: RECORD ---
213
- // 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"]);
214
297
 
215
298
  const recorded = [];
216
- let summaryText = null;
217
- let updateText = null;
299
+ const lifecycle = [];
300
+ const actions = [];
218
301
 
219
302
  for (const cmd of commands) {
220
- const entry = await this.#record(currentRunId, turn, mode, cmd);
221
- if (!entry) continue;
222
-
223
- if (entry.scheme === "summarize") summaryText = entry.body;
224
- else if (entry.scheme === "update") updateText = entry.body;
225
- else recorded.push(entry);
226
- }
227
-
228
- // If model sent both, summary wins
229
- if (summaryText && updateText) updateText = null;
230
-
231
- // If model sent neither, heal from content
232
- let statusHealed = false;
233
- if (!summaryText && !updateText) {
234
- const healed = ResponseHealer.healStatus(content, commands);
235
- summaryText = healed.summaryText;
236
- updateText = healed.updateText;
237
- statusHealed = true;
238
- }
239
-
240
- // Record healed status
241
- if (summaryText) {
242
- const summaryPath = await this.#knownStore.slugPath(
243
- currentRunId,
244
- "summarize",
245
- summaryText,
246
- );
247
- await this.#knownStore.upsert(
248
- currentRunId,
249
- turn,
250
- summaryPath,
251
- summaryText,
252
- "summary",
253
- );
254
- } else if (updateText) {
255
- const updatePath = await this.#knownStore.slugPath(
256
- currentRunId,
257
- "update",
258
- updateText,
259
- );
260
- await this.#knownStore.upsert(
303
+ const entry = await this.#record(
261
304
  currentRunId,
305
+ currentLoopId,
262
306
  turn,
263
- updatePath,
264
- updateText,
265
- "info",
307
+ mode,
308
+ cmd,
266
309
  );
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
+ }
267
318
  }
268
319
 
269
320
  // --- PHASE 2: DISPATCH ---
270
- // Handlers perform side effects: promote, demote, patch, propose.
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;
271
328
 
272
329
  let hasErrors = false;
273
- for (const entry of recorded) {
274
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
275
- await this.#hooks.entry.created.emit(entry);
276
- }
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
+ }
277
342
 
278
- // Materialize proposals (e.g. file plugin applies accumulated revisions)
279
- await this.#hooks.turn.proposing.emit({ rummy, recorded });
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
+ }
280
363
 
281
- // Check if any dispatched entries ended in error state
282
- for (const entry of recorded) {
283
- const row = await this.#db.get_entry_state.get({
284
- run_id: currentRunId,
285
- path: entry.resultPath || entry.path,
286
- });
287
- if (row?.state === "error") hasErrors = true;
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
+ }
387
+
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
+ }
400
+
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);
288
419
  }
289
420
 
290
- // Errors override summarize the model thinks it's done but it's not
291
- 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;
292
437
  summaryText = null;
293
- updateText = "Tool errors detected — retry or investigate.";
438
+ }
439
+
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;
294
447
  }
295
448
 
296
449
  // --- Classify for return value ---
297
450
 
298
451
  const actionCalls = recorded.filter((e) =>
299
- ["get", "store", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
452
+ ["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
300
453
  e.scheme,
301
454
  ),
302
455
  );
@@ -318,7 +471,7 @@ export default class TurnExecutor {
318
471
 
319
472
  const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
320
473
 
321
- return {
474
+ const turnResult = {
322
475
  turn,
323
476
  turnId: turnRow.id,
324
477
  actionCalls,
@@ -335,25 +488,32 @@ export default class TurnExecutor {
335
488
  options?.temperature ??
336
489
  Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
337
490
  contextSize,
491
+ assembledTokens,
338
492
  usage: result.usage,
339
493
  };
494
+
495
+ await this.#hooks.turn.completed.emit(turnResult);
496
+
497
+ return turnResult;
340
498
  }
341
499
 
342
500
  /**
343
501
  * Record a parsed command as a known_entries row.
344
502
  * Returns the recorded entry descriptor, or null if rejected/skipped.
345
503
  */
346
- async #record(runId, turn, mode, cmd) {
347
- // Mode enforcement — reject prohibited commands in ask mode
348
- if (mode === "ask") {
504
+ async #record(runId, loopId, turn, mode, cmd) {
505
+ // Mode enforcement — reject prohibited commands in ask/panic mode
506
+ if (mode === "ask" || mode === "panic") {
349
507
  if (cmd.name === "sh") {
350
508
  console.warn("[RUMMY] Rejected <sh> in ask mode");
351
509
  return null;
352
510
  }
353
- if (cmd.name === "set" && cmd.path) {
511
+ if (cmd.name === "set" && cmd.path && cmd.body) {
354
512
  const scheme = KnownStore.scheme(cmd.path);
355
513
  if (scheme === null) {
356
- 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
+ );
357
517
  return null;
358
518
  }
359
519
  }
@@ -377,21 +537,40 @@ export default class TurnExecutor {
377
537
 
378
538
  const scheme = cmd.name;
379
539
 
380
- // Structural tags — record and return (no handler dispatch)
540
+ // Structural tags — recorded like any other entry
381
541
  if (scheme === "summarize" || scheme === "update") {
382
- 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
+ };
383
557
  }
384
558
 
385
559
  // Unknown — deduplicated, sticky
386
560
  if (scheme === "unknown") {
387
561
  const existingValues = await this.#knownStore.getUnknownValues(runId);
388
- 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
+ }
389
566
  const unknownPath = await this.#knownStore.slugPath(
390
567
  runId,
391
568
  "unknown",
392
569
  cmd.body,
393
570
  );
394
- await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, "full");
571
+ await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
572
+ loopId,
573
+ });
395
574
  return {
396
575
  scheme,
397
576
  path: unknownPath,
@@ -401,15 +580,14 @@ export default class TurnExecutor {
401
580
  };
402
581
  }
403
582
 
404
- // Normalize path — encode spaces in scheme:// paths
405
583
  const rawTarget = cmd.path || cmd.command || cmd.question || "";
406
- const target = rawTarget.includes("://")
407
- ? rawTarget.replace(
408
- /:\/\/(.*)$/,
409
- (_, rest) => `://${encodeURIComponent(decodeURIComponent(rest))}`,
410
- )
411
- : rawTarget;
412
- const resultPath = await this.#knownStore.dedup(runId, scheme, target);
584
+ const target = rawTarget;
585
+ const resultPath = await this.#knownStore.dedup(
586
+ runId,
587
+ scheme,
588
+ target,
589
+ turn,
590
+ );
413
591
 
414
592
  // Pass parsed command fields through as attributes
415
593
  const { name: _, ...attributes } = cmd;
@@ -418,9 +596,72 @@ export default class TurnExecutor {
418
596
  // known tool or naked write → known:// slug from body
419
597
  if (scheme === "known" || (scheme === "set" && !cmd.path)) {
420
598
  if (!cmd.body) return null;
421
- const knownPath =
422
- cmd.path || (await this.#knownStore.slugPath(runId, "known", cmd.body));
423
- await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, "full");
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
+ });
424
665
  return {
425
666
  scheme: "known",
426
667
  path: knownPath,
@@ -430,28 +671,65 @@ export default class TurnExecutor {
430
671
  };
431
672
  }
432
673
 
433
- // Record the entry
434
674
  const body = cmd.body || cmd.command || cmd.question || "";
435
- const state = this.#initialState(scheme);
436
- await this.#knownStore.upsert(runId, turn, resultPath, body, state, {
437
- attributes,
438
- });
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
+ );
439
695
 
440
696
  return {
441
- scheme,
442
- path: resultPath,
443
- body,
444
- attributes,
445
- state,
446
- resultPath,
697
+ scheme: filtered.scheme,
698
+ path: filtered.path,
699
+ body: filtered.body,
700
+ attributes: filtered.attributes,
701
+ status: 200,
702
+ resultPath: filtered.path,
447
703
  };
448
704
  }
449
705
 
450
- /**
451
- * Initial state for a recorded command entry.
452
- * All entries start at "full". Handlers change state during dispatch.
453
- */
454
- #initialState(_scheme) {
455
- return "full";
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
+ }
456
734
  }
457
735
  }