@possumtech/rummy 0.2.8 → 0.3.1

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 (114) hide show
  1. package/.env.example +13 -2
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +440 -106
  5. package/migrations/001_initial_schema.sql +5 -3
  6. package/package.json +17 -5
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +252 -55
  9. package/src/agent/ContextAssembler.js +20 -4
  10. package/src/agent/KnownStore.js +82 -25
  11. package/src/agent/ProjectAgent.js +4 -1
  12. package/src/agent/ResponseHealer.js +86 -32
  13. package/src/agent/TurnExecutor.js +542 -207
  14. package/src/agent/XmlParser.js +77 -41
  15. package/src/agent/known_store.sql +68 -4
  16. package/src/agent/schemes.sql +3 -0
  17. package/src/agent/tokens.js +7 -21
  18. package/src/agent/turns.sql +15 -1
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +15 -0
  21. package/src/hooks/PluginContext.js +14 -1
  22. package/src/hooks/RummyContext.js +16 -4
  23. package/src/hooks/ToolRegistry.js +77 -19
  24. package/src/llm/LlmProvider.js +27 -8
  25. package/src/llm/OpenAiClient.js +20 -0
  26. package/src/llm/OpenRouterClient.js +24 -2
  27. package/src/llm/XaiClient.js +47 -2
  28. package/src/plugins/ask_user/README.md +4 -4
  29. package/src/plugins/ask_user/ask_user.js +5 -5
  30. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  31. package/src/plugins/budget/README.md +31 -0
  32. package/src/plugins/budget/budget.js +55 -0
  33. package/src/plugins/cp/README.md +5 -4
  34. package/src/plugins/cp/cp.js +10 -6
  35. package/src/plugins/cp/cpDoc.js +29 -0
  36. package/src/plugins/engine/engine.sql +1 -8
  37. package/src/plugins/engine/turn_context.sql +4 -9
  38. package/src/plugins/env/README.md +3 -4
  39. package/src/plugins/env/env.js +5 -5
  40. package/src/plugins/env/envDoc.js +29 -0
  41. package/src/plugins/file/README.md +9 -12
  42. package/src/plugins/file/file.js +34 -35
  43. package/src/plugins/get/README.md +2 -2
  44. package/src/plugins/get/get.js +77 -6
  45. package/src/plugins/get/getDoc.js +51 -0
  46. package/src/plugins/hedberg/hedberg.js +2 -1
  47. package/src/plugins/hedberg/matcher.js +10 -29
  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 +19 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +23 -17
  57. package/src/plugins/known/knownDoc.js +34 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +27 -6
  60. package/src/plugins/mv/mvDoc.js +45 -0
  61. package/src/plugins/performed/README.md +15 -0
  62. package/src/plugins/performed/performed.js +45 -0
  63. package/src/plugins/persona/persona.js +78 -0
  64. package/src/plugins/previous/README.md +3 -2
  65. package/src/plugins/previous/previous.js +33 -24
  66. package/src/plugins/progress/README.md +1 -2
  67. package/src/plugins/progress/progress.js +33 -21
  68. package/src/plugins/prompt/README.md +5 -5
  69. package/src/plugins/prompt/prompt.js +15 -17
  70. package/src/plugins/rm/README.md +4 -4
  71. package/src/plugins/rm/rm.js +32 -20
  72. package/src/plugins/rm/rmDoc.js +30 -0
  73. package/src/plugins/rpc/README.md +15 -28
  74. package/src/plugins/rpc/rpc.js +42 -77
  75. package/src/plugins/set/README.md +13 -12
  76. package/src/plugins/set/set.js +107 -16
  77. package/src/plugins/set/setDoc.js +49 -0
  78. package/src/plugins/sh/README.md +4 -4
  79. package/src/plugins/sh/sh.js +5 -5
  80. package/src/plugins/sh/shDoc.js +29 -0
  81. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  82. package/src/plugins/summarize/README.md +6 -5
  83. package/src/plugins/summarize/summarize.js +7 -6
  84. package/src/plugins/summarize/summarizeDoc.js +33 -0
  85. package/src/plugins/telemetry/telemetry.js +16 -9
  86. package/src/plugins/think/README.md +20 -0
  87. package/src/plugins/think/think.js +5 -0
  88. package/src/plugins/unknown/README.md +6 -5
  89. package/src/plugins/unknown/unknown.js +12 -9
  90. package/src/plugins/unknown/unknownDoc.js +31 -0
  91. package/src/plugins/update/README.md +3 -8
  92. package/src/plugins/update/update.js +7 -6
  93. package/src/plugins/update/updateDoc.js +33 -0
  94. package/src/server/ClientConnection.js +59 -45
  95. package/src/server/RpcRegistry.js +52 -4
  96. package/src/sql/v_model_context.sql +10 -25
  97. package/src/plugins/ask_user/docs.md +0 -2
  98. package/src/plugins/cp/docs.md +0 -2
  99. package/src/plugins/current/README.md +0 -14
  100. package/src/plugins/current/current.js +0 -47
  101. package/src/plugins/env/docs.md +0 -4
  102. package/src/plugins/get/docs.md +0 -10
  103. package/src/plugins/known/docs.md +0 -3
  104. package/src/plugins/mv/docs.md +0 -2
  105. package/src/plugins/rm/docs.md +0 -6
  106. package/src/plugins/set/docs.md +0 -6
  107. package/src/plugins/sh/docs.md +0 -2
  108. package/src/plugins/skills/README.md +0 -25
  109. package/src/plugins/store/README.md +0 -20
  110. package/src/plugins/store/docs.md +0 -6
  111. package/src/plugins/store/store.js +0 -63
  112. package/src/plugins/summarize/docs.md +0 -4
  113. package/src/plugins/unknown/docs.md +0 -5
  114. package/src/plugins/update/docs.md +0 -4
@@ -19,6 +19,68 @@ export default class TurnExecutor {
19
19
  this.#knownStore = knownStore;
20
20
  }
21
21
 
22
+ /**
23
+ * Rebuild turn_context from v_model_context, then assemble messages.
24
+ * Called at turn start and again after any fidelity demotion within the turn.
25
+ */
26
+ async #materializeTurnContext({
27
+ runId,
28
+ loopId,
29
+ turn,
30
+ systemPrompt,
31
+ mode,
32
+ toolSet,
33
+ contextSize,
34
+ demoted,
35
+ }) {
36
+ await this.#db.clear_turn_context.run({ run_id: runId, turn });
37
+ const viewRows = await this.#db.get_model_context.all({ run_id: runId });
38
+ for (const row of viewRows) {
39
+ const scheme = row.scheme || "file";
40
+ const projectedBody = await this.#hooks.tools.view(scheme, {
41
+ path: row.path,
42
+ scheme,
43
+ body: row.body,
44
+ attributes: row.attributes ? JSON.parse(row.attributes) : null,
45
+ fidelity: row.fidelity,
46
+ category: row.category,
47
+ });
48
+ await this.#db.insert_turn_context.run({
49
+ run_id: runId,
50
+ loop_id: loopId,
51
+ turn,
52
+ ordinal: row.ordinal,
53
+ path: row.path,
54
+ fidelity: row.fidelity,
55
+ status: row.status,
56
+ body: projectedBody ?? "",
57
+ tokens: countTokens(projectedBody ?? ""),
58
+ attributes: row.attributes,
59
+ category: row.category,
60
+ source_turn: row.turn,
61
+ });
62
+ }
63
+ const rows = await this.#db.get_turn_context.all({ run_id: runId, turn });
64
+ const lastCtx = await this.#db.get_last_context_tokens.get({
65
+ run_id: runId,
66
+ });
67
+ const lastContextTokens = lastCtx?.context_tokens ?? 0;
68
+ const messages = await ContextAssembler.assembleFromTurnContext(
69
+ rows,
70
+ {
71
+ type: mode,
72
+ systemPrompt,
73
+ contextSize,
74
+ demoted,
75
+ toolSet,
76
+ lastContextTokens,
77
+ turn,
78
+ },
79
+ this.#hooks,
80
+ );
81
+ return { rows, messages, lastContextTokens };
82
+ }
83
+
22
84
  async execute({
23
85
  mode,
24
86
  project,
@@ -28,11 +90,25 @@ export default class TurnExecutor {
28
90
  currentLoopId,
29
91
  requestedModel,
30
92
  loopPrompt,
31
- noContext,
93
+ loopIteration,
94
+ noRepo,
95
+ toolSet,
96
+ inRecovery = false,
32
97
  contextSize,
33
98
  options,
34
99
  signal,
35
100
  }) {
101
+ const RECOVERY_EXCLUDED = new Set([
102
+ "sh",
103
+ "env",
104
+ "search",
105
+ "ask_user",
106
+ "set",
107
+ ]);
108
+ const effectiveToolSet = inRecovery
109
+ ? new Set([...toolSet].filter((t) => !RECOVERY_EXCLUDED.has(t)))
110
+ : toolSet;
111
+
36
112
  const turn = await this.#knownStore.nextTurn(currentRunId);
37
113
 
38
114
  const turnRow = await this.#db.create_turn.get({
@@ -71,7 +147,8 @@ export default class TurnExecutor {
71
147
  runId: currentRunId,
72
148
  loopId: currentLoopId,
73
149
  turnId: turnRow.id,
74
- noContext,
150
+ noRepo,
151
+ toolSet: effectiveToolSet,
75
152
  contextSize,
76
153
  systemPrompt: null,
77
154
  loopPrompt,
@@ -83,6 +160,7 @@ export default class TurnExecutor {
83
160
  mode,
84
161
  prompt: loopPrompt,
85
162
  isContinuation: options?.isContinuation,
163
+ loopIteration,
86
164
  });
87
165
 
88
166
  await this.#hooks.processTurn(rummy);
@@ -109,38 +187,24 @@ export default class TurnExecutor {
109
187
  });
110
188
 
111
189
  // Materialize turn_context: VIEW rows projected through tools
112
- await this.#db.clear_turn_context.run({ run_id: currentRunId, turn });
113
- const viewRows = await this.#db.get_model_context.all({
114
- run_id: currentRunId,
115
- });
116
- for (const row of viewRows) {
117
- const scheme = row.scheme || "file";
118
- const projectedBody = await this.#hooks.tools.view(scheme, {
119
- path: row.path,
120
- scheme,
121
- body: row.body,
122
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
123
- fidelity: row.fidelity,
124
- category: row.category,
125
- });
126
-
127
- await this.#db.insert_turn_context.run({
128
- run_id: currentRunId,
129
- loop_id: currentLoopId,
190
+ const demoted = [];
191
+ let { rows, messages, lastContextTokens } =
192
+ await this.#materializeTurnContext({
193
+ runId: currentRunId,
194
+ loopId: currentLoopId,
130
195
  turn,
131
- ordinal: row.ordinal,
132
- path: row.path,
133
- fidelity: row.fidelity,
134
- status: row.status,
135
- body: projectedBody ?? "",
136
- tokens: countTokens(projectedBody ?? ""),
137
- attributes: row.attributes,
138
- category: row.category,
139
- source_turn: row.turn,
196
+ systemPrompt,
197
+ mode,
198
+ toolSet: effectiveToolSet,
199
+ contextSize,
200
+ demoted,
140
201
  });
141
- }
142
202
 
143
- const demoted = [];
203
+ await this.#hooks.context.materialized.emit({
204
+ runId: currentRunId,
205
+ turn,
206
+ rowCount: rows.length,
207
+ });
144
208
 
145
209
  await this.#hooks.run.progress.emit({
146
210
  projectId,
@@ -149,94 +213,74 @@ export default class TurnExecutor {
149
213
  status: "thinking",
150
214
  });
151
215
 
152
- // Assemble messages from projected system prompt + materialized turn_context
153
- let rows = await this.#db.get_turn_context.all({
154
- run_id: currentRunId,
155
- turn,
156
- });
157
- let messages = await ContextAssembler.assembleFromTurnContext(
216
+ const budgetResult = await this.#hooks.budget.enforce({
217
+ contextSize,
218
+ messages,
158
219
  rows,
159
- {
160
- type: mode,
161
- systemPrompt,
162
- contextSize,
163
- demoted,
164
- },
165
- this.#hooks,
166
- );
167
-
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;
220
+ lastPromptTokens: lastContextTokens,
221
+ });
222
+ messages = budgetResult.messages;
223
+ rows = budgetResult.rows;
224
+ let assembledTokens =
225
+ budgetResult.assembledTokens ??
226
+ messages.reduce((sum, m) => sum + countTokens(m.content), 0);
227
+
228
+ if (budgetResult.status === 413) {
229
+ if (loopIteration === 1) {
230
+ // Prompt Demotion: first-turn overflow demote incoming prompt to summary
231
+ const promptRow = rows.findLast(
232
+ (r) => r.category === "prompt" && r.scheme === "prompt",
233
+ );
234
+ if (promptRow) {
188
235
  await this.#knownStore.setFidelity(
189
236
  currentRunId,
190
- entry.path,
237
+ promptRow.path,
191
238
  "summary",
192
239
  );
193
- excess -= entry.tokens;
194
- demoted.push(entry.path);
195
240
  }
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,
241
+ const reMat = await this.#materializeTurnContext({
242
+ runId: currentRunId,
243
+ loopId: currentLoopId,
244
+ turn,
245
+ systemPrompt,
246
+ mode,
247
+ toolSet: effectiveToolSet,
248
+ contextSize,
249
+ demoted,
250
+ });
251
+ rows = reMat.rows;
252
+ messages = reMat.messages;
253
+ const recheck = await this.#hooks.budget.enforce({
254
+ contextSize,
255
+ messages,
256
+ rows,
257
+ lastPromptTokens: reMat.lastContextTokens,
258
+ });
259
+ messages = recheck.messages;
260
+ rows = recheck.rows;
261
+ assembledTokens =
262
+ recheck.assembledTokens ??
263
+ messages.reduce((sum, m) => sum + countTokens(m.content), 0);
264
+ if (recheck.status === 413) {
265
+ return {
229
266
  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
- );
267
+ turnId: turnRow.id,
268
+ status: 413,
269
+ assembledTokens,
270
+ contextSize,
271
+ overflow: recheck.overflow,
272
+ };
239
273
  }
274
+ } else {
275
+ // Base context too large even without new prompt — genuine failure
276
+ return {
277
+ turn,
278
+ turnId: turnRow.id,
279
+ status: 413,
280
+ assembledTokens,
281
+ contextSize,
282
+ overflow: budgetResult.overflow,
283
+ };
240
284
  }
241
285
  }
242
286
 
@@ -246,14 +290,34 @@ export default class TurnExecutor {
246
290
  runId: currentRunId,
247
291
  });
248
292
 
249
- // Store assembled messages as audit
250
293
  // Call LLM
251
294
  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
- );
295
+ let rawResult;
296
+ const isTransient = (e) =>
297
+ /\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
298
+ e.message,
299
+ );
300
+
301
+ for (let llmAttempt = 0; ; llmAttempt++) {
302
+ try {
303
+ rawResult = await this.#llmProvider.completion(
304
+ filteredMessages,
305
+ requestedModel,
306
+ { temperature: options?.temperature, signal },
307
+ );
308
+ break;
309
+ } catch (err) {
310
+ if (isTransient(err) && llmAttempt < 3) {
311
+ const delay = 1000 * 2 ** llmAttempt;
312
+ console.warn(
313
+ `[RUMMY] Transient LLM error (attempt ${llmAttempt + 1}/3): ${err.message.slice(0, 120)}. Retrying in ${delay}ms.`,
314
+ );
315
+ await new Promise((r) => setTimeout(r, delay));
316
+ continue;
317
+ }
318
+ throw err;
319
+ }
320
+ }
257
321
  const result = await this.#hooks.llm.response.filter(rawResult, {
258
322
  model: requestedModel,
259
323
  projectId,
@@ -277,6 +341,19 @@ export default class TurnExecutor {
277
341
  // Parse and emit — plugins handle audit storage
278
342
  const { commands, unparsed } = XmlParser.parse(content);
279
343
 
344
+ // Ensure reasoning_content captures both API field and <think> tag
345
+ if (responseMessage) {
346
+ const thinkCmds = commands.filter((c) => c.name === "think");
347
+ const thinkText = thinkCmds
348
+ .map((c) => c.body)
349
+ .filter(Boolean)
350
+ .join("\n");
351
+ const apiReasoning = responseMessage.reasoning_content || "";
352
+ const parts = [apiReasoning, thinkText].filter(Boolean);
353
+ responseMessage.reasoning_content =
354
+ parts.length > 0 ? parts.join("\n") : null;
355
+ }
356
+
280
357
  const systemMsg = filteredMessages.find((m) => m.role === "system");
281
358
  const userMsg = filteredMessages.find((m) => m.role === "user");
282
359
  await this.#hooks.turn.response.emit({
@@ -287,16 +364,21 @@ export default class TurnExecutor {
287
364
  content,
288
365
  commands,
289
366
  unparsed,
367
+ assembledTokens,
368
+ contextSize,
290
369
  systemMsg: systemMsg?.content,
291
370
  userMsg: userMsg?.content,
292
371
  });
293
372
 
294
373
  // --- PHASE 1: RECORD ---
295
- // Every command becomes an entry. No execution yet.
374
+ // Split lifecycle signals from action commands.
375
+ // Lifecycle signals (summarize, update, unknown, known) are state
376
+ // declarations — always recorded, never 409'd by sequential dispatch.
377
+ const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
296
378
 
297
379
  const recorded = [];
298
- let summaryText = null;
299
- let updateText = null;
380
+ const lifecycle = [];
381
+ const actions = [];
300
382
 
301
383
  for (const cmd of commands) {
302
384
  const entry = await this.#record(
@@ -307,94 +389,217 @@ export default class TurnExecutor {
307
389
  cmd,
308
390
  );
309
391
  if (!entry) continue;
392
+ recorded.push(entry);
310
393
 
311
- if (entry.scheme === "summarize") summaryText = entry.body;
312
- else if (entry.scheme === "update") updateText = entry.body;
313
- else recorded.push(entry);
314
- }
315
-
316
- // If model sent both, summary wins
317
- if (summaryText && updateText) updateText = null;
318
-
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
- }
327
-
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
- );
394
+ if (LIFECYCLE.has(entry.scheme)) {
395
+ lifecycle.push(entry);
396
+ } else {
397
+ actions.push(entry);
398
+ }
357
399
  }
358
400
 
359
401
  // --- PHASE 2: DISPATCH ---
360
- // Handlers perform side effects: promote, demote, patch, propose.
402
+ let hasErrors = false;
403
+ let hasProposed = false;
404
+ let abortAfter = null;
405
+ const dispatched = [...lifecycle];
361
406
 
362
- for (const entry of recorded) {
407
+ // Lifecycle signals first always dispatched, never aborted.
408
+ for (const entry of lifecycle) {
409
+ await this.#hooks.tool.before.emit({ entry, rummy });
363
410
  await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
411
+ await this.#hooks.tool.after.emit({ entry, rummy });
364
412
  await this.#hooks.entry.created.emit(entry);
365
413
  }
366
414
 
367
- // Materialize proposals (e.g. file plugin applies accumulated revisions)
368
- await this.#hooks.turn.proposing.emit({ rummy, recorded });
415
+ for (const entry of actions) {
416
+ if (abortAfter) {
417
+ const errorMsg = `Aborted — preceding <${abortAfter}> requires resolution.`;
418
+ await this.#knownStore.upsert(
419
+ currentRunId,
420
+ turn,
421
+ entry.resultPath || entry.path,
422
+ errorMsg,
423
+ 409,
424
+ { attributes: { error: errorMsg }, loopId: currentLoopId },
425
+ );
426
+ hasErrors = true;
427
+ continue;
428
+ }
429
+
430
+ await this.#hooks.tool.before.emit({ entry, rummy });
431
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
432
+ await this.#hooks.tool.after.emit({ entry, rummy });
433
+ await this.#hooks.entry.created.emit(entry);
434
+ dispatched.push(entry);
369
435
 
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
436
  const row = await this.#db.get_entry_state.get({
375
437
  run_id: currentRunId,
376
438
  path: entry.resultPath || entry.path,
377
439
  });
378
- if (row?.status >= 400) hasErrors = true;
379
- if (row?.status === 202) hasProposed = true;
440
+ if (row?.status === 202) {
441
+ hasProposed = true;
442
+ abortAfter = entry.scheme;
443
+ } else if (row?.status >= 400) {
444
+ hasErrors = true;
445
+ abortAfter = entry.scheme;
446
+ }
380
447
  }
381
448
 
382
- // Errors override summarize the model thinks it's done but it's not
383
- if (hasErrors && summaryText) {
384
- summaryText = null;
385
- updateText = "Tool errors detected — retry or investigate.";
449
+ // Materialize proposals only if we dispatched actions
450
+ if (!abortAfter || hasProposed) {
451
+ await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
452
+ }
453
+
454
+ // Recheck after materialization (set handler may create proposals)
455
+ if (!hasProposed && !hasErrors) {
456
+ for (const entry of actions) {
457
+ const row = await this.#db.get_entry_state.get({
458
+ run_id: currentRunId,
459
+ path: entry.resultPath || entry.path,
460
+ });
461
+ if (row?.status === 202) hasProposed = true;
462
+ if (row?.status >= 400) hasErrors = true;
463
+ }
386
464
  }
387
465
 
388
- // Proposals override summarize outcome unknown until user resolves
389
- if (hasProposed && summaryText) {
466
+ // Turn Demotion: if end-of-turn context exceeds ceiling, demote this
467
+ // turn's data entries and the incoming prompt to summary, then force a
468
+ // budget recovery phase before continuing.
469
+ let budgetRecovery = null;
470
+ // Use actual prompt_tokens from this turn's LLM response as the ground-truth
471
+ // token count for post-turn budget checks — more accurate than the estimate.
472
+ const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
473
+ if (contextSize) {
474
+ const postMat = await this.#materializeTurnContext({
475
+ runId: currentRunId,
476
+ loopId: currentLoopId,
477
+ turn,
478
+ systemPrompt,
479
+ mode,
480
+ toolSet: effectiveToolSet,
481
+ contextSize,
482
+ demoted,
483
+ });
484
+ const postBudget = await this.#hooks.budget.enforce({
485
+ contextSize,
486
+ messages: postMat.messages,
487
+ rows: postMat.rows,
488
+ lastPromptTokens: currentPromptTokens,
489
+ });
490
+ if (postBudget.status === 413) {
491
+ // Demote this turn's data entries.
492
+ const demotedEntries = await this.#db.demote_turn_data_entries.all({
493
+ run_id: currentRunId,
494
+ turn,
495
+ });
496
+ const paths = demotedEntries.map((r) => r.path).join(", ");
497
+
498
+ // Also summarize the prompt — forces the model to earn it back.
499
+ const promptRow = postMat.rows.find((r) => r.scheme === "prompt");
500
+ if (promptRow) {
501
+ await this.#knownStore.setFidelity(
502
+ currentRunId,
503
+ promptRow.path,
504
+ "summary",
505
+ );
506
+ }
507
+
508
+ // Re-materialize after both demotions for accurate token count.
509
+ const recoveryMat = await this.#materializeTurnContext({
510
+ runId: currentRunId,
511
+ loopId: currentLoopId,
512
+ turn,
513
+ systemPrompt,
514
+ mode,
515
+ toolSet: effectiveToolSet,
516
+ contextSize,
517
+ demoted,
518
+ });
519
+ const recoveryBudget = await this.#hooks.budget.enforce({
520
+ contextSize,
521
+ messages: recoveryMat.messages,
522
+ rows: recoveryMat.rows,
523
+ lastPromptTokens: currentPromptTokens,
524
+ });
525
+ const safeLevel = Math.floor(contextSize * 0.9);
526
+ const tokensToFree = Math.max(
527
+ 0,
528
+ recoveryBudget.assembledTokens - safeLevel,
529
+ );
530
+
531
+ const promptLine =
532
+ tokensToFree > 0
533
+ ? `Info: Prompt auto-summarized. Full prompt restores automatically when you free ${tokensToFree} tokens.`
534
+ : "Info: Prompt auto-summarized. It will restore automatically.";
535
+ const body = [
536
+ "Error 413: Context Size Exceeded",
537
+ "",
538
+ "Required: YOU MUST demote larger and/or less relevant items to optimize your context.",
539
+ `Info: ${paths} have been automatically summarized to avoid overflow.`,
540
+ promptLine,
541
+ "Info: YOU MAY use bulk patterns to demote and promote entries by pattern.",
542
+ "Info: Well-designed paths and summaries improve context management.",
543
+ 'Example: <set path="known://people/*" fidelity="summary"/>',
544
+ ].join("\n");
545
+
546
+ await this.#knownStore.upsert(
547
+ currentRunId,
548
+ turn,
549
+ `budget://${currentLoopId}/${turn}`,
550
+ body,
551
+ 413,
552
+ { loopId: currentLoopId },
553
+ );
554
+
555
+ budgetRecovery = {
556
+ target: safeLevel,
557
+ promptPath: promptRow?.path ?? null,
558
+ };
559
+ }
560
+ }
561
+
562
+ // Lifecycle signals are always available — never 409'd.
563
+ const summaryEntry = lifecycle.find((e) => e.scheme === "summarize");
564
+ const updateEntry = lifecycle.find((e) => e.scheme === "update");
565
+ let summaryText = summaryEntry?.body || null;
566
+ let updateText = updateEntry?.body || null;
567
+
568
+ // If model sent both, update wins — if it can't decide, it's not done
569
+ if (summaryText && updateText) summaryText = null;
570
+
571
+ // If model says "done" but actions failed, override — the model's
572
+ // assertion that it's done is false if it failed to do what it tried.
573
+ if (summaryText && hasErrors) {
574
+ console.warn(
575
+ "[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
576
+ );
577
+ // Mark the recorded summarize entry as 409 so the model sees it was rejected
578
+ if (summaryEntry?.path) {
579
+ await this.#knownStore.resolve(
580
+ currentRunId,
581
+ summaryEntry.path,
582
+ 409,
583
+ "Overridden — actions in this turn failed. Use <update/> until resolved.",
584
+ );
585
+ }
586
+ updateText = summaryText;
390
587
  summaryText = null;
391
- updateText = "Awaiting approval for proposed changes.";
588
+ }
589
+
590
+ // If model sent neither, heal from content
591
+ let statusHealed = false;
592
+ if (!summaryText && !updateText) {
593
+ const healed = ResponseHealer.healStatus(content, commands);
594
+ summaryText = healed.summaryText;
595
+ updateText = healed.updateText;
596
+ statusHealed = true;
392
597
  }
393
598
 
394
599
  // --- Classify for return value ---
395
600
 
396
601
  const actionCalls = recorded.filter((e) =>
397
- ["get", "store", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
602
+ ["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
398
603
  e.scheme,
399
604
  ),
400
605
  );
@@ -416,7 +621,7 @@ export default class TurnExecutor {
416
621
 
417
622
  const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
418
623
 
419
- return {
624
+ const turnResult = {
420
625
  turn,
421
626
  turnId: turnRow.id,
422
627
  actionCalls,
@@ -429,12 +634,16 @@ export default class TurnExecutor {
429
634
  flags,
430
635
  model: result.model || requestedModel,
431
636
  modelAlias: requestedModel,
432
- temperature:
433
- options?.temperature ??
434
- Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
637
+ temperature: options?.temperature,
435
638
  contextSize,
639
+ assembledTokens,
436
640
  usage: result.usage,
641
+ budgetRecovery,
437
642
  };
643
+
644
+ await this.#hooks.turn.completed.emit(turnResult);
645
+
646
+ return turnResult;
438
647
  }
439
648
 
440
649
  /**
@@ -442,16 +651,17 @@ export default class TurnExecutor {
442
651
  * Returns the recorded entry descriptor, or null if rejected/skipped.
443
652
  */
444
653
  async #record(runId, loopId, turn, mode, cmd) {
445
- // Mode enforcement — reject prohibited commands in ask mode
446
654
  if (mode === "ask") {
447
655
  if (cmd.name === "sh") {
448
656
  console.warn("[RUMMY] Rejected <sh> in ask mode");
449
657
  return null;
450
658
  }
451
- if (cmd.name === "set" && cmd.path) {
659
+ if (cmd.name === "set" && cmd.path && cmd.body) {
452
660
  const scheme = KnownStore.scheme(cmd.path);
453
661
  if (scheme === null) {
454
- console.warn(`[RUMMY] Rejected file set to ${cmd.path} in ask mode`);
662
+ console.warn(
663
+ `[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
664
+ );
455
665
  return null;
456
666
  }
457
667
  }
@@ -475,15 +685,32 @@ export default class TurnExecutor {
475
685
 
476
686
  const scheme = cmd.name;
477
687
 
478
- // Structural tags — record and return (no handler dispatch)
688
+ // Structural tags — recorded like any other entry
479
689
  if (scheme === "summarize" || scheme === "update") {
480
- return { scheme, body: cmd.body, resultPath: null, attributes: null };
690
+ const statusPath = await this.#knownStore.slugPath(
691
+ runId,
692
+ scheme,
693
+ cmd.body,
694
+ );
695
+ await this.#knownStore.upsert(runId, turn, statusPath, cmd.body, 200, {
696
+ loopId,
697
+ });
698
+ return {
699
+ scheme,
700
+ body: cmd.body,
701
+ path: statusPath,
702
+ resultPath: statusPath,
703
+ attributes: null,
704
+ };
481
705
  }
482
706
 
483
707
  // Unknown — deduplicated, sticky
484
708
  if (scheme === "unknown") {
485
709
  const existingValues = await this.#knownStore.getUnknownValues(runId);
486
- if (existingValues.has(cmd.body)) return null;
710
+ if (existingValues.has(cmd.body)) {
711
+ console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
712
+ return null;
713
+ }
487
714
  const unknownPath = await this.#knownStore.slugPath(
488
715
  runId,
489
716
  "unknown",
@@ -502,8 +729,38 @@ export default class TurnExecutor {
502
729
  }
503
730
 
504
731
  const rawTarget = cmd.path || cmd.command || cmd.question || "";
732
+ // Reject paths that are likely reasoning bleed — too long or contain non-printing chars
733
+ if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
734
+ const rejectPath = await this.#knownStore.dedup(
735
+ runId,
736
+ scheme,
737
+ `${scheme}://invalid`,
738
+ turn,
739
+ );
740
+ await this.#knownStore.upsert(
741
+ runId,
742
+ turn,
743
+ rejectPath,
744
+ `Invalid path: too long or contains non-printing characters`,
745
+ 400,
746
+ { loopId },
747
+ );
748
+ return {
749
+ scheme,
750
+ path: rejectPath,
751
+ body: "",
752
+ attributes: {},
753
+ status: 400,
754
+ resultPath: rejectPath,
755
+ };
756
+ }
505
757
  const target = rawTarget;
506
- const resultPath = await this.#knownStore.dedup(runId, scheme, target);
758
+ const resultPath = await this.#knownStore.dedup(
759
+ runId,
760
+ scheme,
761
+ target,
762
+ turn,
763
+ );
507
764
 
508
765
  // Pass parsed command fields through as attributes
509
766
  const { name: _, ...attributes } = cmd;
@@ -512,9 +769,72 @@ export default class TurnExecutor {
512
769
  // known tool or naked write → known:// slug from body
513
770
  if (scheme === "known" || (scheme === "set" && !cmd.path)) {
514
771
  if (!cmd.body) return null;
515
- const knownPath =
516
- cmd.path || (await this.#knownStore.slugPath(runId, "known", cmd.body));
772
+
773
+ // Size gate: reject entries > 512 tokens — force atomic entries
774
+ const entryTokens = countTokens(cmd.body);
775
+ const MAX_ENTRY_TOKENS = 512;
776
+ if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
777
+ const rejectPath = await this.#knownStore.slugPath(
778
+ runId,
779
+ scheme,
780
+ cmd.body,
781
+ );
782
+ await this.#knownStore.upsert(
783
+ runId,
784
+ turn,
785
+ rejectPath,
786
+ `Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
787
+ 413,
788
+ { loopId },
789
+ );
790
+ return {
791
+ scheme,
792
+ path: rejectPath,
793
+ body: "",
794
+ resultPath: rejectPath,
795
+ attributes,
796
+ status: 413,
797
+ };
798
+ }
799
+
800
+ let knownPath = cmd.path;
801
+ if (!knownPath) {
802
+ knownPath = await this.#knownStore.slugPath(
803
+ runId,
804
+ "known",
805
+ cmd.body,
806
+ cmd.summary,
807
+ );
808
+ }
809
+ // Dedup: if this exact path already exists, update rather than duplicate
810
+ const existing = await this.#knownStore.getEntriesByPattern(
811
+ runId,
812
+ knownPath,
813
+ null,
814
+ );
815
+ if (existing.length > 0) {
816
+ // Path exists — update body and turn, skip creating a new entry
817
+ await this.#knownStore.upsert(
818
+ runId,
819
+ turn,
820
+ existing[0].path,
821
+ cmd.body || existing[0].body,
822
+ 200,
823
+ {
824
+ attributes,
825
+ loopId,
826
+ },
827
+ );
828
+ return {
829
+ scheme: "known",
830
+ path: existing[0].path,
831
+ body: cmd.body || existing[0].body,
832
+ resultPath: existing[0].path,
833
+ attributes,
834
+ };
835
+ }
517
836
  await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
837
+ attributes,
518
838
  loopId,
519
839
  });
520
840
  return {
@@ -526,20 +846,35 @@ export default class TurnExecutor {
526
846
  };
527
847
  }
528
848
 
529
- // Record the entry — 200 OK, handlers change status during dispatch
530
849
  const body = cmd.body || cmd.command || cmd.question || "";
531
- await this.#knownStore.upsert(runId, turn, resultPath, body, 200, {
532
- attributes,
533
- loopId,
534
- });
850
+
851
+ // Filter: plugins can validate/transform before recording
852
+ const filtered = await this.#hooks.entry.recording.filter(
853
+ { scheme, path: resultPath, body, attributes, status: 200 },
854
+ { runId, turn, loopId },
855
+ );
856
+ if (filtered.status >= 400) return filtered;
857
+
858
+ // Record the entry — 200 OK, handlers change status during dispatch
859
+ await this.#knownStore.upsert(
860
+ runId,
861
+ turn,
862
+ filtered.path,
863
+ filtered.body,
864
+ 200,
865
+ {
866
+ attributes: filtered.attributes,
867
+ loopId,
868
+ },
869
+ );
535
870
 
536
871
  return {
537
- scheme,
538
- path: resultPath,
539
- body,
540
- attributes,
872
+ scheme: filtered.scheme,
873
+ path: filtered.path,
874
+ body: filtered.body,
875
+ attributes: filtered.attributes,
541
876
  status: 200,
542
- resultPath,
877
+ resultPath: filtered.path,
543
878
  };
544
879
  }
545
880
  }