@possumtech/rummy 0.5.0 → 2.0.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 (157) hide show
  1. package/.env.example +42 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +934 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +13 -11
  11. package/scriptify/ask_run.js +77 -0
  12. package/service.js +50 -9
  13. package/src/agent/AgentLoop.js +476 -335
  14. package/src/agent/ContextAssembler.js +4 -4
  15. package/src/agent/Entries.js +676 -0
  16. package/src/agent/ProjectAgent.js +30 -18
  17. package/src/agent/TurnExecutor.js +232 -421
  18. package/src/agent/XmlParser.js +99 -33
  19. package/src/agent/budget.js +56 -0
  20. package/src/agent/errors.js +22 -0
  21. package/src/agent/httpStatus.js +39 -0
  22. package/src/agent/known_checks.sql +8 -4
  23. package/src/agent/known_queries.sql +9 -13
  24. package/src/agent/known_store.sql +280 -125
  25. package/src/agent/materializeContext.js +104 -0
  26. package/src/agent/runs.sql +29 -7
  27. package/src/agent/schemes.sql +14 -3
  28. package/src/agent/tokens.js +6 -0
  29. package/src/agent/turns.sql +9 -9
  30. package/src/hooks/HookRegistry.js +6 -5
  31. package/src/hooks/Hooks.js +44 -3
  32. package/src/hooks/PluginContext.js +29 -21
  33. package/src/{server → hooks}/RpcRegistry.js +2 -1
  34. package/src/hooks/RummyContext.js +139 -35
  35. package/src/hooks/ToolRegistry.js +21 -16
  36. package/src/llm/LlmProvider.js +66 -89
  37. package/src/llm/errors.js +21 -0
  38. package/src/llm/retry.js +63 -0
  39. package/src/plugins/ask_user/README.md +1 -1
  40. package/src/plugins/ask_user/ask_user.js +37 -12
  41. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  42. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  43. package/src/plugins/budget/README.md +27 -25
  44. package/src/plugins/budget/budget.js +306 -88
  45. package/src/plugins/cp/README.md +2 -2
  46. package/src/plugins/cp/cp.js +29 -11
  47. package/src/plugins/cp/cpDoc.js +2 -15
  48. package/src/plugins/cp/cpDoc.md +7 -0
  49. package/src/plugins/engine/README.md +2 -2
  50. package/src/plugins/engine/engine.sql +4 -4
  51. package/src/plugins/engine/turn_context.sql +10 -10
  52. package/src/plugins/env/README.md +20 -5
  53. package/src/plugins/env/env.js +45 -6
  54. package/src/plugins/env/envDoc.js +2 -23
  55. package/src/plugins/env/envDoc.md +13 -0
  56. package/src/plugins/error/README.md +16 -0
  57. package/src/plugins/error/error.js +151 -0
  58. package/src/plugins/file/README.md +6 -6
  59. package/src/plugins/file/file.js +15 -2
  60. package/src/plugins/get/README.md +1 -1
  61. package/src/plugins/get/get.js +103 -48
  62. package/src/plugins/get/getDoc.js +2 -32
  63. package/src/plugins/get/getDoc.md +36 -0
  64. package/src/plugins/hedberg/README.md +1 -2
  65. package/src/plugins/hedberg/hedberg.js +8 -4
  66. package/src/plugins/hedberg/matcher.js +16 -17
  67. package/src/plugins/hedberg/normalize.js +0 -48
  68. package/src/plugins/helpers.js +42 -2
  69. package/src/plugins/index.js +146 -123
  70. package/src/plugins/instructions/README.md +35 -9
  71. package/src/plugins/instructions/instructions.js +244 -9
  72. package/src/plugins/instructions/instructions.md +33 -0
  73. package/src/plugins/instructions/instructions_104.md +7 -0
  74. package/src/plugins/instructions/instructions_105.md +38 -0
  75. package/src/plugins/instructions/instructions_106.md +21 -0
  76. package/src/plugins/instructions/instructions_107.md +10 -0
  77. package/src/plugins/instructions/instructions_108.md +0 -0
  78. package/src/plugins/instructions/protocol.js +12 -0
  79. package/src/plugins/known/README.md +2 -2
  80. package/src/plugins/known/known.js +68 -36
  81. package/src/plugins/known/knownDoc.js +2 -17
  82. package/src/plugins/known/knownDoc.md +8 -0
  83. package/src/plugins/log/README.md +48 -0
  84. package/src/plugins/log/log.js +129 -0
  85. package/src/plugins/mv/README.md +2 -2
  86. package/src/plugins/mv/mv.js +55 -22
  87. package/src/plugins/mv/mvDoc.js +2 -18
  88. package/src/plugins/mv/mvDoc.md +10 -0
  89. package/src/plugins/ollama/README.md +15 -0
  90. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  91. package/src/plugins/openai/README.md +17 -0
  92. package/src/plugins/openai/openai.js +120 -0
  93. package/src/plugins/openrouter/README.md +27 -0
  94. package/src/plugins/openrouter/openrouter.js +121 -0
  95. package/src/plugins/persona/README.md +20 -0
  96. package/src/plugins/persona/persona.js +9 -16
  97. package/src/plugins/policy/README.md +21 -0
  98. package/src/plugins/policy/policy.js +29 -14
  99. package/src/plugins/prompt/README.md +1 -1
  100. package/src/plugins/prompt/prompt.js +64 -16
  101. package/src/plugins/rm/README.md +1 -1
  102. package/src/plugins/rm/rm.js +56 -12
  103. package/src/plugins/rm/rmDoc.js +2 -20
  104. package/src/plugins/rm/rmDoc.md +13 -0
  105. package/src/plugins/rpc/README.md +2 -2
  106. package/src/plugins/rpc/rpc.js +525 -296
  107. package/src/plugins/set/README.md +1 -1
  108. package/src/plugins/set/set.js +318 -75
  109. package/src/plugins/set/setDoc.js +2 -35
  110. package/src/plugins/set/setDoc.md +22 -0
  111. package/src/plugins/sh/README.md +28 -5
  112. package/src/plugins/sh/sh.js +50 -6
  113. package/src/plugins/sh/shDoc.js +2 -23
  114. package/src/plugins/sh/shDoc.md +13 -0
  115. package/src/plugins/skill/README.md +23 -0
  116. package/src/plugins/skill/skill.js +14 -18
  117. package/src/plugins/stream/README.md +101 -0
  118. package/src/plugins/stream/stream.js +290 -0
  119. package/src/plugins/telemetry/README.md +1 -1
  120. package/src/plugins/telemetry/telemetry.js +129 -80
  121. package/src/plugins/think/README.md +1 -1
  122. package/src/plugins/think/think.js +12 -0
  123. package/src/plugins/think/thinkDoc.js +2 -15
  124. package/src/plugins/think/thinkDoc.md +7 -0
  125. package/src/plugins/unknown/README.md +3 -3
  126. package/src/plugins/unknown/unknown.js +47 -19
  127. package/src/plugins/unknown/unknownDoc.js +2 -21
  128. package/src/plugins/unknown/unknownDoc.md +11 -0
  129. package/src/plugins/update/README.md +1 -1
  130. package/src/plugins/update/update.js +83 -5
  131. package/src/plugins/update/updateDoc.js +2 -30
  132. package/src/plugins/update/updateDoc.md +8 -0
  133. package/src/plugins/xai/README.md +23 -0
  134. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  135. package/src/plugins/yolo/yolo.js +192 -0
  136. package/src/server/ClientConnection.js +64 -37
  137. package/src/server/SocketServer.js +23 -10
  138. package/src/server/protocol.js +11 -0
  139. package/src/sql/v_model_context.sql +27 -31
  140. package/src/sql/v_run_log.sql +9 -14
  141. package/EXCEPTIONS.md +0 -46
  142. package/FIDELITY_CONTRACT.md +0 -172
  143. package/src/agent/KnownStore.js +0 -337
  144. package/src/agent/ResponseHealer.js +0 -241
  145. package/src/llm/OpenAiClient.js +0 -100
  146. package/src/llm/OpenRouterClient.js +0 -100
  147. package/src/plugins/budget/recovery.js +0 -47
  148. package/src/plugins/instructions/preamble.md +0 -45
  149. package/src/plugins/performed/README.md +0 -15
  150. package/src/plugins/performed/performed.js +0 -45
  151. package/src/plugins/previous/README.md +0 -16
  152. package/src/plugins/previous/previous.js +0 -56
  153. package/src/plugins/progress/README.md +0 -16
  154. package/src/plugins/progress/progress.js +0 -43
  155. package/src/plugins/summarize/README.md +0 -19
  156. package/src/plugins/summarize/summarize.js +0 -32
  157. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -1,130 +1,19 @@
1
1
  import RummyContext from "../hooks/RummyContext.js";
2
- import ContextAssembler from "./ContextAssembler.js";
3
- import ResponseHealer from "./ResponseHealer.js";
4
- import { countTokens } from "./tokens.js";
2
+ import { ContextExceededError } from "../llm/errors.js";
3
+ import materializeContext from "./materializeContext.js";
5
4
  import XmlParser from "./XmlParser.js";
6
5
 
7
- const ACTION_SCHEMES = new Set([
8
- "get",
9
- "set",
10
- "rm",
11
- "mv",
12
- "cp",
13
- "sh",
14
- "env",
15
- "search",
16
- ]);
17
- const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
18
- const READ_SCHEMES = new Set(["get", "env", "search"]);
19
-
20
6
  export default class TurnExecutor {
21
7
  #db;
22
8
  #llmProvider;
23
9
  #hooks;
24
- #knownStore;
10
+ #entries;
25
11
 
26
- constructor(db, llmProvider, hooks, knownStore) {
12
+ constructor(db, llmProvider, hooks, entries) {
27
13
  this.#db = db;
28
14
  this.#llmProvider = llmProvider;
29
15
  this.#hooks = hooks;
30
- this.#knownStore = knownStore;
31
- }
32
-
33
- /**
34
- * Rebuild turn_context from v_model_context, then assemble messages.
35
- * Called at turn start and again after any fidelity demotion within the turn.
36
- */
37
- async #materializeTurnContext({
38
- runId,
39
- loopId,
40
- turn,
41
- systemPrompt,
42
- mode,
43
- toolSet,
44
- contextSize,
45
- demoted,
46
- }) {
47
- await this.#db.clear_turn_context.run({ run_id: runId, turn });
48
- const viewRows = await this.#db.get_model_context.all({ run_id: runId });
49
- for (const row of viewRows) {
50
- const scheme = row.scheme || "file";
51
- const projectedBody = await this.#hooks.tools.view(scheme, {
52
- path: row.path,
53
- scheme,
54
- body: row.body,
55
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
56
- fidelity: row.fidelity,
57
- category: row.category,
58
- });
59
- await this.#db.insert_turn_context.run({
60
- run_id: runId,
61
- loop_id: loopId,
62
- turn,
63
- ordinal: row.ordinal,
64
- path: row.path,
65
- fidelity: row.fidelity,
66
- status: row.status,
67
- body: projectedBody ?? "",
68
- // Full-body token count, not projected. This is the cost to
69
- // promote the entry — the number the model needs to do Token
70
- // Budget math. Projecting the demoted symbol-preview (145
71
- // tokens for a 2108-token file) was misleading the model into
72
- // promotes that blew the Token Budget by 10-30× per entry.
73
- tokens: countTokens(row.body ?? ""),
74
- attributes: row.attributes,
75
- category: row.category,
76
- source_turn: row.turn,
77
- });
78
- }
79
- const rows = await this.#db.get_turn_context.all({ run_id: runId, turn });
80
- const lastCtx = await this.#db.get_last_context_tokens.get({
81
- run_id: runId,
82
- });
83
- const lastContextTokens = lastCtx?.context_tokens ?? 0;
84
-
85
- // Baseline materialization — assemble with model's promoted spending
86
- // removed (promoted data, promoted logging). The resulting size is the
87
- // fixed overhead the model can't reduce without further demotion.
88
- const baselineRows = rows.filter(
89
- (r) =>
90
- !(
91
- (r.category === "data" || r.category === "logging") &&
92
- r.fidelity === "promoted"
93
- ),
94
- );
95
- const baselineMessages = await ContextAssembler.assembleFromTurnContext(
96
- baselineRows,
97
- {
98
- type: mode,
99
- systemPrompt,
100
- contextSize,
101
- demoted,
102
- toolSet,
103
- lastContextTokens,
104
- turn,
105
- },
106
- this.#hooks,
107
- );
108
- const baselineTokens = baselineMessages.reduce(
109
- (sum, m) => sum + countTokens(m.content),
110
- 0,
111
- );
112
-
113
- const messages = await ContextAssembler.assembleFromTurnContext(
114
- rows,
115
- {
116
- type: mode,
117
- systemPrompt,
118
- contextSize,
119
- demoted,
120
- toolSet,
121
- lastContextTokens,
122
- turn,
123
- baselineTokens,
124
- },
125
- this.#hooks,
126
- );
127
- return { rows, messages, lastContextTokens };
16
+ this.#entries = entries;
128
17
  }
129
18
 
130
19
  async execute({
@@ -138,24 +27,16 @@ export default class TurnExecutor {
138
27
  loopPrompt,
139
28
  loopIteration,
140
29
  noRepo,
30
+ noWeb,
31
+ noInteraction,
32
+ noProposals,
33
+ yolo,
141
34
  toolSet,
142
- inRecovery = false,
143
35
  contextSize,
144
36
  options,
145
37
  signal,
146
38
  }) {
147
- const RECOVERY_EXCLUDED = new Set([
148
- "sh",
149
- "env",
150
- "search",
151
- "ask_user",
152
- "set",
153
- ]);
154
- const effectiveToolSet = inRecovery
155
- ? new Set([...toolSet].filter((t) => !RECOVERY_EXCLUDED.has(t)))
156
- : toolSet;
157
-
158
- const turn = await this.#knownStore.nextTurn(currentRunId);
39
+ const turn = await this.#entries.nextTurn(currentRunId);
159
40
 
160
41
  const turnRow = await this.#db.create_turn.get({
161
42
  run_id: currentRunId,
@@ -179,7 +60,7 @@ export default class TurnExecutor {
179
60
  {
180
61
  hooks: this.#hooks,
181
62
  db: this.#db,
182
- store: this.#knownStore,
63
+ store: this.#entries,
183
64
  project,
184
65
  type: mode,
185
66
  sequence: turn,
@@ -187,13 +68,17 @@ export default class TurnExecutor {
187
68
  loopId: currentLoopId,
188
69
  turnId: turnRow.id,
189
70
  noRepo,
190
- toolSet: effectiveToolSet,
71
+ noWeb,
72
+ noInteraction,
73
+ noProposals,
74
+ yolo,
75
+ toolSet,
191
76
  contextSize,
192
77
  systemPrompt: null,
193
78
  loopPrompt,
194
79
  },
195
80
  );
196
- // Plugins write prompt/progress/instructions entries
81
+ // Plugins write prompt/instructions entries
197
82
  await this.#hooks.turn.started.emit({
198
83
  rummy,
199
84
  mode,
@@ -205,44 +90,32 @@ export default class TurnExecutor {
205
90
  await this.#hooks.processTurn(rummy);
206
91
 
207
92
  // Project instructions://system through the instructions tool's projection
208
- const instrEntry = await this.#knownStore.getEntriesByPattern(
209
- currentRunId,
210
- "instructions://system",
211
- null,
212
- );
213
- const instrAttrs = instrEntry[0]
214
- ? await this.#knownStore.getAttributes(
215
- currentRunId,
216
- "instructions://system",
217
- )
218
- : null;
219
- const systemPrompt = await this.#hooks.tools.view("instructions", {
220
- path: "instructions://system",
221
- scheme: "instructions",
222
- body: instrEntry[0]?.body || "",
223
- attributes: instrAttrs,
224
- fidelity: "promoted",
225
- category: "system",
226
- });
93
+ const systemPrompt =
94
+ await this.#hooks.instructions.resolveSystemPrompt(rummy);
227
95
 
228
96
  // Materialize turn_context: VIEW rows projected through tools
229
97
  const demoted = [];
230
- let { rows, messages, lastContextTokens } =
231
- await this.#materializeTurnContext({
232
- runId: currentRunId,
233
- loopId: currentLoopId,
234
- turn,
235
- systemPrompt,
236
- mode,
237
- toolSet: effectiveToolSet,
238
- contextSize,
239
- demoted,
240
- });
98
+ const budgetCtx = {
99
+ runId: currentRunId,
100
+ loopId: currentLoopId,
101
+ turn,
102
+ systemPrompt,
103
+ mode,
104
+ toolSet,
105
+ demoted,
106
+ loopIteration,
107
+ };
108
+ const initial = await materializeContext({
109
+ db: this.#db,
110
+ hooks: this.#hooks,
111
+ contextSize,
112
+ ...budgetCtx,
113
+ });
241
114
 
242
115
  await this.#hooks.context.materialized.emit({
243
116
  runId: currentRunId,
244
117
  turn,
245
- rowCount: rows.length,
118
+ rowCount: initial.rows.length,
246
119
  });
247
120
 
248
121
  await this.#hooks.run.progress.emit({
@@ -254,73 +127,26 @@ export default class TurnExecutor {
254
127
 
255
128
  const budgetResult = await this.#hooks.budget.enforce({
256
129
  contextSize,
257
- messages,
258
- rows,
259
- lastPromptTokens: lastContextTokens,
130
+ messages: initial.messages,
131
+ rows: initial.rows,
132
+ lastPromptTokens: initial.lastContextTokens,
133
+ ctx: budgetCtx,
134
+ rummy,
260
135
  });
261
- messages = budgetResult.messages;
262
- rows = budgetResult.rows;
263
- let assembledTokens =
264
- budgetResult.assembledTokens ??
265
- messages.reduce((sum, m) => sum + countTokens(m.content), 0);
266
-
267
- if (budgetResult.status === 413) {
268
- if (loopIteration === 1) {
269
- // Prompt Demotion: first-turn overflow — demote incoming prompt to summary
270
- const promptRow = rows.findLast(
271
- (r) => r.category === "prompt" && r.scheme === "prompt",
272
- );
273
- if (promptRow) {
274
- await this.#knownStore.setFidelity(
275
- currentRunId,
276
- promptRow.path,
277
- "demoted",
278
- );
279
- }
280
- const reMat = await this.#materializeTurnContext({
281
- runId: currentRunId,
282
- loopId: currentLoopId,
283
- turn,
284
- systemPrompt,
285
- mode,
286
- toolSet: effectiveToolSet,
287
- contextSize,
288
- demoted,
289
- });
290
- rows = reMat.rows;
291
- messages = reMat.messages;
292
- const recheck = await this.#hooks.budget.enforce({
293
- contextSize,
294
- messages,
295
- rows,
296
- lastPromptTokens: reMat.lastContextTokens,
297
- });
298
- messages = recheck.messages;
299
- rows = recheck.rows;
300
- assembledTokens =
301
- recheck.assembledTokens ??
302
- messages.reduce((sum, m) => sum + countTokens(m.content), 0);
303
- if (recheck.status === 413) {
304
- return {
305
- turn,
306
- turnId: turnRow.id,
307
- status: 413,
308
- assembledTokens,
309
- contextSize,
310
- overflow: recheck.overflow,
311
- };
312
- }
313
- } else {
314
- // Base context too large even without new prompt — genuine failure
315
- return {
316
- turn,
317
- turnId: turnRow.id,
318
- status: 413,
319
- assembledTokens,
320
- contextSize,
321
- overflow: budgetResult.overflow,
322
- };
323
- }
136
+ const messages = budgetResult.messages;
137
+ const assembledTokens = budgetResult.assembledTokens;
138
+
139
+ if (!budgetResult.ok) {
140
+ return {
141
+ turn,
142
+ turnId: turnRow.id,
143
+ recorded: [],
144
+ summaryText: null,
145
+ updateText: null,
146
+ assembledTokens,
147
+ contextSize,
148
+ overflow: budgetResult.overflow,
149
+ };
324
150
  }
325
151
 
326
152
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
@@ -332,49 +158,37 @@ export default class TurnExecutor {
332
158
  turn,
333
159
  });
334
160
 
335
- // Call LLM
161
+ // Call LLM. Transient-error retry + context-exceeded detection live
162
+ // in LlmProvider; context-exceeded surfaces as ContextExceededError.
336
163
  await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
337
164
  let rawResult;
338
- const isTransient = (e) =>
339
- /\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
340
- e.message,
341
- );
342
- const isContextExceeded = (e) =>
343
- /\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
344
- e.message,
165
+ try {
166
+ rawResult = await this.#llmProvider.completion(
167
+ filteredMessages,
168
+ requestedModel,
169
+ { temperature: options?.temperature, signal },
345
170
  );
346
-
347
- for (let llmAttempt = 0; ; llmAttempt++) {
348
- try {
349
- rawResult = await this.#llmProvider.completion(
350
- filteredMessages,
351
- requestedModel,
352
- { temperature: options?.temperature, signal },
353
- );
354
- break;
355
- } catch (err) {
356
- if (isTransient(err) && llmAttempt < 3) {
357
- const delay = 1000 * 2 ** llmAttempt;
358
- console.warn(
359
- `[RUMMY] Transient LLM error (attempt ${llmAttempt + 1}/3): ${err.message.slice(0, 120)}. Retrying in ${delay}ms.`,
360
- );
361
- await new Promise((r) => setTimeout(r, delay));
362
- continue;
363
- }
364
- if (isContextExceeded(err)) {
365
- console.warn(
366
- `[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
367
- );
368
- return {
369
- turn,
370
- turnId: turnRow.id,
371
- status: 413,
372
- assembledTokens,
373
- contextSize,
374
- };
375
- }
376
- throw err;
171
+ } catch (err) {
172
+ if (err instanceof ContextExceededError) {
173
+ await this.#hooks.error.log.emit({
174
+ store: this.#entries,
175
+ runId: currentRunId,
176
+ turn,
177
+ loopId: currentLoopId,
178
+ message: `LLM context exceeded: ${err.message}`,
179
+ status: 413,
180
+ });
181
+ return {
182
+ turn,
183
+ turnId: turnRow.id,
184
+ recorded: [],
185
+ summaryText: null,
186
+ updateText: null,
187
+ assembledTokens,
188
+ contextSize,
189
+ };
377
190
  }
191
+ throw err;
378
192
  }
379
193
  const result = await this.#hooks.llm.response.filter(rawResult, {
380
194
  model: requestedModel,
@@ -387,7 +201,10 @@ export default class TurnExecutor {
387
201
  usage: result.usage,
388
202
  });
389
203
  const responseMessage = result.choices?.[0]?.message;
390
- const content = responseMessage?.content || "";
204
+ // A valid completion response always carries content (possibly
205
+ // empty) on the message; protect against that specific case so
206
+ // downstream parsers see a string.
207
+ const content = responseMessage?.content ? responseMessage.content : "";
391
208
 
392
209
  await this.#hooks.run.progress.emit({
393
210
  projectId,
@@ -397,19 +214,38 @@ export default class TurnExecutor {
397
214
  });
398
215
 
399
216
  // Parse and emit — plugins handle audit storage
400
- const { commands, unparsed } = XmlParser.parse(content);
217
+ const { commands, warnings, unparsed } = XmlParser.parse(content);
218
+ for (const w of warnings) {
219
+ await this.#hooks.error.log.emit({
220
+ store: this.#entries,
221
+ runId: currentRunId,
222
+ turn,
223
+ message: w,
224
+ loopId: currentLoopId,
225
+ status: 422,
226
+ });
227
+ }
228
+ if (commands.length === 0 && !!unparsed?.trim() && warnings.length === 0) {
229
+ await this.#hooks.error.log.emit({
230
+ store: this.#entries,
231
+ runId: currentRunId,
232
+ turn,
233
+ loopId: currentLoopId,
234
+ message: "Response contained no actionable tags.",
235
+ status: 422,
236
+ });
237
+ }
401
238
 
402
- // Ensure reasoning_content captures both API field and <think> tag
239
+ // Merge reasoning contributions from subscribers (think plugin's
240
+ // <think> tag, other plugin reasoning sources). Filter starts with
241
+ // the API-provided reasoning_content and layers on each plugin's
242
+ // contribution.
403
243
  if (responseMessage) {
404
- const thinkCmds = commands.filter((c) => c.name === "think");
405
- const thinkText = thinkCmds
406
- .map((c) => c.body)
407
- .filter(Boolean)
408
- .join("\n");
409
- const apiReasoning = responseMessage.reasoning_content || "";
410
- const parts = [apiReasoning, thinkText].filter(Boolean);
411
- responseMessage.reasoning_content =
412
- parts.length > 0 ? parts.join("\n") : null;
244
+ const seed = responseMessage.reasoning_content
245
+ ? responseMessage.reasoning_content
246
+ : "";
247
+ const merged = await this.#hooks.llm.reasoning.filter(seed, { commands });
248
+ responseMessage.reasoning_content = merged ? merged : null;
413
249
  }
414
250
 
415
251
  const systemMsg = filteredMessages.find((m) => m.role === "system");
@@ -445,171 +281,127 @@ export default class TurnExecutor {
445
281
  // Sequential queue. Each tool completes before the next starts.
446
282
  // On failure: abort remaining. On proposal: notify client, await
447
283
  // resolution, continue.
448
- let hasErrors = false;
284
+ // Narration text outside tags is fine when the turn also emitted
285
+ // at least one command — "OK", "Let me check:", reasoning prefixes
286
+ // are natural. Parse warnings and no-tags responses already emitted
287
+ // errors above; dispatch crashes and failed entries emit below.
449
288
  let abortAfter = null;
450
289
 
451
290
  for (const entry of recorded) {
291
+ if (entry.state === "failed" || entry.state === "cancelled") continue;
292
+
452
293
  if (abortAfter) {
453
294
  const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
454
- await this.#knownStore.upsert(
455
- currentRunId,
295
+ await this.#entries.set({
296
+ runId: currentRunId,
456
297
  turn,
457
- entry.resultPath || entry.path,
458
- errorMsg,
459
- 409,
460
- { attributes: { error: errorMsg }, loopId: currentLoopId },
461
- );
462
- hasErrors = true;
298
+ path: entry.resultPath || entry.path,
299
+ body: errorMsg,
300
+ state: "failed",
301
+ outcome: "aborted",
302
+ attributes: { error: errorMsg },
303
+ loopId: currentLoopId,
304
+ });
463
305
  continue;
464
306
  }
465
307
 
466
308
  await this.#hooks.tool.before.emit({ entry, rummy });
467
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
309
+ try {
310
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
311
+ } catch (dispatchErr) {
312
+ await this.#hooks.error.log.emit({
313
+ store: this.#entries,
314
+ runId: currentRunId,
315
+ turn,
316
+ loopId: currentLoopId,
317
+ message: `Dispatch crash in ${entry.scheme}: ${dispatchErr.message}`,
318
+ status: 500,
319
+ });
320
+ abortAfter = entry.scheme;
321
+ continue;
322
+ }
468
323
  await this.#hooks.tool.after.emit({ entry, rummy });
469
324
  await this.#hooks.entry.created.emit(entry);
470
325
 
471
- // Materialize proposals for this entry (set revisions → 202)
472
- await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
326
+ // Plugins (e.g. set) materialize pending proposals from the
327
+ // recorded entry — e.g. search/replace revisions set:// 202.
328
+ await this.#hooks.proposal.prepare.emit({ rummy, recorded: [entry] });
473
329
 
474
330
  // Check for any proposals created by this entry's dispatch
475
- const proposed = await this.#knownStore.getUnresolved(currentRunId);
331
+ const proposed = await this.#entries.getUnresolved(currentRunId);
476
332
  for (const p of proposed) {
477
- await this.#hooks.turn.proposal.emit({
333
+ await this.#hooks.proposal.pending.emit({
478
334
  projectId,
479
335
  run: currentAlias,
480
336
  proposed: [p],
337
+ rummy,
481
338
  });
482
- await this.#knownStore.waitForResolution(currentRunId, p.path);
483
- const resolved = await this.#db.get_entry_state.get({
484
- run_id: currentRunId,
485
- path: p.path,
486
- });
339
+ await this.#entries.waitForResolution(currentRunId, p.path);
340
+ const resolved = await this.#entries.getState(currentRunId, p.path);
487
341
  if (resolved?.status >= 400) {
488
- hasErrors = true;
342
+ await this.#hooks.error.log.emit({
343
+ store: this.#entries,
344
+ runId: currentRunId,
345
+ turn,
346
+ loopId: currentLoopId,
347
+ message: `Proposal ${p.path} rejected: status ${resolved.status}.`,
348
+ status: resolved.status,
349
+ });
489
350
  abortAfter = entry.scheme;
490
351
  }
491
352
  }
492
353
 
493
- // Also check the entry itself for direct failures
494
- if (!hasErrors) {
354
+ if (!abortAfter) {
495
355
  const entryPath = entry.resultPath || entry.path;
496
- const row = await this.#db.get_entry_state.get({
497
- run_id: currentRunId,
498
- path: entryPath,
499
- });
356
+ const row = await this.#entries.getState(currentRunId, entryPath);
500
357
  if (row?.status >= 400) {
501
- hasErrors = true;
358
+ await this.#hooks.error.log.emit({
359
+ store: this.#entries,
360
+ runId: currentRunId,
361
+ turn,
362
+ loopId: currentLoopId,
363
+ message: `Entry ${entryPath} failed: status ${row.status}.`,
364
+ status: row.status,
365
+ });
502
366
  abortAfter = entry.scheme;
503
367
  }
504
368
  }
505
369
  }
506
370
 
507
- // Turn Demotion: if end-of-turn context exceeds ceiling, demote this
508
- // turn's data entries and the incoming prompt to summary, then force a
509
- // budget recovery phase before continuing.
510
- let budgetRecovery = null;
511
- // Use actual prompt_tokens from this turn's LLM response as the ground-truth
512
- // Post-dispatch budget check — demotion handled by budget plugin
513
- if (contextSize) {
514
- const postMat = await this.#materializeTurnContext({
515
- runId: currentRunId,
516
- loopId: currentLoopId,
517
- turn,
518
- systemPrompt,
519
- mode,
520
- toolSet: effectiveToolSet,
521
- contextSize,
522
- demoted,
523
- });
524
- budgetRecovery = await this.#hooks.budget.postDispatch({
525
- contextSize,
526
- messages: postMat.messages,
527
- rows: postMat.rows,
528
- runId: currentRunId,
529
- loopId: currentLoopId,
530
- turn,
531
- db: this.#db,
532
- store: this.#knownStore,
533
- });
534
- }
535
-
536
- const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
537
- const updateEntry = recorded.findLast((e) => e.scheme === "update");
538
- let summaryText = summaryEntry?.body || null;
539
- let updateText = updateEntry?.body || null;
540
-
541
- // If model sent both, last signal wins — respects the model's final intent
542
- if (summaryText && updateText) {
543
- const lastLifecycle = recorded.findLast(
544
- (e) => e.scheme === "summarize" || e.scheme === "update",
545
- );
546
- if (lastLifecycle.scheme === "summarize") updateText = null;
547
- else summaryText = null;
548
- }
549
-
550
- // If model says "done" but actions failed, override — the model's
551
- // assertion that it's done is false if it failed to do what it tried.
552
- if (summaryText && hasErrors) {
553
- console.warn(
554
- "[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
555
- );
556
- // Mark the recorded summarize entry as 409 so the model sees it was rejected
557
- if (summaryEntry?.path) {
558
- await this.#knownStore.resolve(
559
- currentRunId,
560
- summaryEntry.path,
561
- 409,
562
- "Overridden — actions in this turn failed. Use <update/> until resolved.",
563
- );
564
- }
565
- updateText = summaryText;
566
- summaryText = null;
567
- }
568
-
569
- // If model sent neither, heal from content
570
- let statusHealed = false;
571
- if (!summaryText && !updateText) {
572
- const healed = ResponseHealer.healStatus(content, commands);
573
- summaryText = healed.summaryText;
574
- updateText = healed.updateText;
575
- statusHealed = true;
576
- }
577
-
578
- // --- Classify for return value ---
579
-
580
- const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
581
- const writeCalls = recorded.filter(
582
- (e) =>
583
- e.scheme === "known" ||
584
- (e.scheme === "set" && !e.attributes?.blocks && !e.attributes?.search),
585
- );
586
- const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
371
+ // Turn Demotion: budget plugin re-materializes end-of-turn context
372
+ // and demotes this turn's promoted entries on overflow. Overflow
373
+ // emits an error (status 413) via the unified error channel.
374
+ await this.#hooks.budget.postDispatch({
375
+ contextSize,
376
+ ctx: budgetCtx,
377
+ rummy,
378
+ });
587
379
 
588
- const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
589
- const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
590
- const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
591
- const flags = { hasAct, hasReads, hasWrites };
380
+ const { summaryText, updateText } = await this.#hooks.update.resolve({
381
+ recorded,
382
+ content,
383
+ commands,
384
+ runId: currentRunId,
385
+ turn,
386
+ loopId: currentLoopId,
387
+ rummy,
388
+ });
592
389
 
593
390
  const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
594
391
 
595
392
  const turnResult = {
596
393
  turn,
597
394
  turnId: turnRow.id,
598
- actionCalls,
599
- writeCalls,
600
- unknownCalls,
395
+ recorded,
601
396
  summaryText,
602
397
  updateText,
603
- statusHealed,
604
- askUserCmd: askUserEntry || null,
605
- flags,
606
- model: result.model || requestedModel,
398
+ askUserCmd: askUserEntry,
399
+ model: result.model ? result.model : requestedModel,
607
400
  modelAlias: requestedModel,
608
401
  temperature: options?.temperature,
609
402
  contextSize,
610
403
  assembledTokens,
611
404
  usage: result.usage,
612
- budgetRecovery,
613
405
  };
614
406
 
615
407
  await this.#hooks.turn.completed.emit(turnResult);
@@ -623,59 +415,78 @@ export default class TurnExecutor {
623
415
  */
624
416
  async #record(runId, loopId, turn, mode, cmd) {
625
417
  const scheme = cmd.name;
626
- const rawTarget = cmd.path || cmd.command || cmd.question || "";
418
+ // Each tool's XmlParser shape surfaces exactly one of these
419
+ // three fields as its addressable target. Treat absent as empty
420
+ // so the length/control-char validation below catches bad shapes
421
+ // rather than letting an undefined slip through.
422
+ let rawTarget = "";
423
+ if (cmd.path) rawTarget = cmd.path;
424
+ else if (cmd.command) rawTarget = cmd.command;
425
+ else if (cmd.question) rawTarget = cmd.question;
627
426
  // Reject paths that are likely reasoning bleed — too long or contain non-printing chars
628
427
  if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
629
- const rejectPath = await this.#knownStore.dedup(
428
+ const rejectPath = await this.#entries.logPath(
630
429
  runId,
631
- scheme,
632
- `${scheme}://invalid`,
633
430
  turn,
431
+ scheme,
432
+ "invalid",
634
433
  );
635
- await this.#knownStore.upsert(
434
+ await this.#entries.set({
636
435
  runId,
637
436
  turn,
638
- rejectPath,
639
- `Invalid path: too long or contains non-printing characters`,
640
- 400,
641
- { loopId },
642
- );
437
+ path: rejectPath,
438
+ body: `Invalid path: too long or contains non-printing characters`,
439
+ state: "failed",
440
+ outcome: "validation",
441
+ attributes: { action: scheme },
442
+ loopId,
443
+ });
643
444
  return {
644
445
  scheme,
645
446
  path: rejectPath,
646
447
  body: "",
647
448
  attributes: {},
648
- status: 400,
449
+ state: "failed",
450
+ outcome: "validation",
649
451
  resultPath: rejectPath,
650
452
  };
651
453
  }
652
454
  const target = rawTarget;
653
- const resultPath = await this.#knownStore.dedup(
654
- runId,
655
- scheme,
656
- target,
657
- turn,
658
- );
455
+ const resultPath = await this.#entries.logPath(runId, turn, scheme, target);
659
456
 
660
457
  // Pass parsed command fields through as attributes
661
458
  const { name: _, ...attributes } = cmd;
662
459
  if (cmd.path) attributes.path = target;
663
460
 
664
- const body = cmd.body || cmd.command || cmd.question || "";
461
+ // Same per-shape resolution as rawTarget; the three sources are
462
+ // mutually exclusive per tool. Empty string when none set.
463
+ let body = "";
464
+ if (cmd.body) body = cmd.body;
465
+ else if (cmd.command) body = cmd.command;
466
+ else if (cmd.question) body = cmd.question;
665
467
 
666
468
  // Filter: plugins can validate/transform before recording
667
469
  const filtered = await this.#hooks.entry.recording.filter(
668
- { scheme, path: resultPath, body, attributes, status: 200 },
669
- { runId, turn, loopId, mode },
470
+ {
471
+ scheme,
472
+ path: resultPath,
473
+ body,
474
+ attributes,
475
+ state: "resolved",
476
+ outcome: null,
477
+ },
478
+ { store: this.#entries, runId, turn, loopId, mode },
670
479
  );
671
- if (filtered.status >= 400) return filtered;
480
+ if (filtered.state === "failed" || filtered.state === "cancelled") {
481
+ return filtered;
482
+ }
672
483
 
673
484
  return {
674
485
  scheme: filtered.scheme,
675
486
  path: filtered.path,
676
487
  body: filtered.body,
677
488
  attributes: filtered.attributes,
678
- status: 200,
489
+ state: "resolved",
679
490
  resultPath: filtered.path,
680
491
  };
681
492
  }