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