@possumtech/rummy 0.5.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 -5
  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 -330
  13. package/src/agent/ContextAssembler.js +4 -4
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +229 -421
  17. package/src/agent/XmlParser.js +99 -33
  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 -125
  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 +29 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +135 -35
  33. package/src/hooks/ToolRegistry.js +21 -16
  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 -25
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -25
  41. package/src/plugins/budget/budget.js +260 -88
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +29 -11
  44. package/src/plugins/cp/cpDoc.js +2 -15
  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 +45 -6
  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 -2
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +103 -48
  59. package/src/plugins/get/getDoc.js +2 -32
  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 +42 -2
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +122 -9
  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 +67 -36
  78. package/src/plugins/known/knownDoc.js +2 -17
  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 +55 -22
  84. package/src/plugins/mv/mvDoc.js +2 -18
  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 +58 -16
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +56 -12
  100. package/src/plugins/rm/rmDoc.js +2 -20
  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 -75
  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 +50 -6
  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 -18
  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 +129 -80
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +12 -0
  120. package/src/plugins/think/thinkDoc.js +2 -15
  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 +47 -19
  124. package/src/plugins/unknown/unknownDoc.js +2 -21
  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 -30
  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/v_model_context.sql +27 -31
  136. package/src/sql/v_run_log.sql +9 -14
  137. package/EXCEPTIONS.md +0 -46
  138. package/FIDELITY_CONTRACT.md +0 -172
  139. package/src/agent/KnownStore.js +0 -337
  140. package/src/agent/ResponseHealer.js +0 -241
  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 -45
  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 -56
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -43
  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 -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,15 @@ export default class TurnExecutor {
138
27
  loopPrompt,
139
28
  loopIteration,
140
29
  noRepo,
30
+ noWeb,
31
+ noInteraction,
32
+ noProposals,
141
33
  toolSet,
142
- inRecovery = false,
143
34
  contextSize,
144
35
  options,
145
36
  signal,
146
37
  }) {
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);
38
+ const turn = await this.#entries.nextTurn(currentRunId);
159
39
 
160
40
  const turnRow = await this.#db.create_turn.get({
161
41
  run_id: currentRunId,
@@ -179,7 +59,7 @@ export default class TurnExecutor {
179
59
  {
180
60
  hooks: this.#hooks,
181
61
  db: this.#db,
182
- store: this.#knownStore,
62
+ store: this.#entries,
183
63
  project,
184
64
  type: mode,
185
65
  sequence: turn,
@@ -187,13 +67,16 @@ export default class TurnExecutor {
187
67
  loopId: currentLoopId,
188
68
  turnId: turnRow.id,
189
69
  noRepo,
190
- toolSet: effectiveToolSet,
70
+ noWeb,
71
+ noInteraction,
72
+ noProposals,
73
+ toolSet,
191
74
  contextSize,
192
75
  systemPrompt: null,
193
76
  loopPrompt,
194
77
  },
195
78
  );
196
- // Plugins write prompt/progress/instructions entries
79
+ // Plugins write prompt/instructions entries
197
80
  await this.#hooks.turn.started.emit({
198
81
  rummy,
199
82
  mode,
@@ -205,44 +88,32 @@ export default class TurnExecutor {
205
88
  await this.#hooks.processTurn(rummy);
206
89
 
207
90
  // 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
- });
91
+ const systemPrompt =
92
+ await this.#hooks.instructions.resolveSystemPrompt(rummy);
227
93
 
228
94
  // Materialize turn_context: VIEW rows projected through tools
229
95
  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
- });
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
+ });
241
112
 
242
113
  await this.#hooks.context.materialized.emit({
243
114
  runId: currentRunId,
244
115
  turn,
245
- rowCount: rows.length,
116
+ rowCount: initial.rows.length,
246
117
  });
247
118
 
248
119
  await this.#hooks.run.progress.emit({
@@ -254,73 +125,26 @@ export default class TurnExecutor {
254
125
 
255
126
  const budgetResult = await this.#hooks.budget.enforce({
256
127
  contextSize,
257
- messages,
258
- rows,
259
- lastPromptTokens: lastContextTokens,
128
+ messages: initial.messages,
129
+ rows: initial.rows,
130
+ lastPromptTokens: initial.lastContextTokens,
131
+ ctx: budgetCtx,
132
+ rummy,
260
133
  });
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
- }
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
+ };
324
148
  }
325
149
 
326
150
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
@@ -332,49 +156,37 @@ export default class TurnExecutor {
332
156
  turn,
333
157
  });
334
158
 
335
- // Call LLM
159
+ // Call LLM. Transient-error retry + context-exceeded detection live
160
+ // in LlmProvider; context-exceeded surfaces as ContextExceededError.
336
161
  await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
337
162
  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,
163
+ try {
164
+ rawResult = await this.#llmProvider.completion(
165
+ filteredMessages,
166
+ requestedModel,
167
+ { temperature: options?.temperature, signal },
345
168
  );
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;
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
+ };
377
188
  }
189
+ throw err;
378
190
  }
379
191
  const result = await this.#hooks.llm.response.filter(rawResult, {
380
192
  model: requestedModel,
@@ -387,7 +199,10 @@ export default class TurnExecutor {
387
199
  usage: result.usage,
388
200
  });
389
201
  const responseMessage = result.choices?.[0]?.message;
390
- 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 : "";
391
206
 
392
207
  await this.#hooks.run.progress.emit({
393
208
  projectId,
@@ -397,19 +212,38 @@ export default class TurnExecutor {
397
212
  });
398
213
 
399
214
  // Parse and emit — plugins handle audit storage
400
- 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
+ }
401
236
 
402
- // 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.
403
241
  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;
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;
413
247
  }
414
248
 
415
249
  const systemMsg = filteredMessages.find((m) => m.role === "system");
@@ -445,171 +279,126 @@ export default class TurnExecutor {
445
279
  // Sequential queue. Each tool completes before the next starts.
446
280
  // On failure: abort remaining. On proposal: notify client, await
447
281
  // resolution, continue.
448
- 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.
449
286
  let abortAfter = null;
450
287
 
451
288
  for (const entry of recorded) {
289
+ if (entry.state === "failed" || entry.state === "cancelled") continue;
290
+
452
291
  if (abortAfter) {
453
292
  const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
454
- await this.#knownStore.upsert(
455
- currentRunId,
293
+ await this.#entries.set({
294
+ runId: currentRunId,
456
295
  turn,
457
- entry.resultPath || entry.path,
458
- errorMsg,
459
- 409,
460
- { attributes: { error: errorMsg }, loopId: currentLoopId },
461
- );
462
- 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
+ });
463
303
  continue;
464
304
  }
465
305
 
466
306
  await this.#hooks.tool.before.emit({ entry, rummy });
467
- 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
+ }
468
321
  await this.#hooks.tool.after.emit({ entry, rummy });
469
322
  await this.#hooks.entry.created.emit(entry);
470
323
 
471
- // Materialize proposals for this entry (set revisions → 202)
472
- 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] });
473
327
 
474
328
  // Check for any proposals created by this entry's dispatch
475
- const proposed = await this.#knownStore.getUnresolved(currentRunId);
329
+ const proposed = await this.#entries.getUnresolved(currentRunId);
476
330
  for (const p of proposed) {
477
- await this.#hooks.turn.proposal.emit({
331
+ await this.#hooks.proposal.pending.emit({
478
332
  projectId,
479
333
  run: currentAlias,
480
334
  proposed: [p],
481
335
  });
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
- });
336
+ await this.#entries.waitForResolution(currentRunId, p.path);
337
+ const resolved = await this.#entries.getState(currentRunId, p.path);
487
338
  if (resolved?.status >= 400) {
488
- 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
+ });
489
347
  abortAfter = entry.scheme;
490
348
  }
491
349
  }
492
350
 
493
- // Also check the entry itself for direct failures
494
- if (!hasErrors) {
351
+ if (!abortAfter) {
495
352
  const entryPath = entry.resultPath || entry.path;
496
- const row = await this.#db.get_entry_state.get({
497
- run_id: currentRunId,
498
- path: entryPath,
499
- });
353
+ const row = await this.#entries.getState(currentRunId, entryPath);
500
354
  if (row?.status >= 400) {
501
- 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
+ });
502
363
  abortAfter = entry.scheme;
503
364
  }
504
365
  }
505
366
  }
506
367
 
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");
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
+ });
587
376
 
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 };
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
+ });
592
386
 
593
387
  const askUserEntry = recorded.find((e) => e.scheme === "ask_user");
594
388
 
595
389
  const turnResult = {
596
390
  turn,
597
391
  turnId: turnRow.id,
598
- actionCalls,
599
- writeCalls,
600
- unknownCalls,
392
+ recorded,
601
393
  summaryText,
602
394
  updateText,
603
- statusHealed,
604
- askUserCmd: askUserEntry || null,
605
- flags,
606
- model: result.model || requestedModel,
395
+ askUserCmd: askUserEntry,
396
+ model: result.model ? result.model : requestedModel,
607
397
  modelAlias: requestedModel,
608
398
  temperature: options?.temperature,
609
399
  contextSize,
610
400
  assembledTokens,
611
401
  usage: result.usage,
612
- budgetRecovery,
613
402
  };
614
403
 
615
404
  await this.#hooks.turn.completed.emit(turnResult);
@@ -623,59 +412,78 @@ export default class TurnExecutor {
623
412
  */
624
413
  async #record(runId, loopId, turn, mode, cmd) {
625
414
  const scheme = cmd.name;
626
- 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;
627
423
  // Reject paths that are likely reasoning bleed — too long or contain non-printing chars
628
424
  if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
629
- const rejectPath = await this.#knownStore.dedup(
425
+ const rejectPath = await this.#entries.logPath(
630
426
  runId,
631
- scheme,
632
- `${scheme}://invalid`,
633
427
  turn,
428
+ scheme,
429
+ "invalid",
634
430
  );
635
- await this.#knownStore.upsert(
431
+ await this.#entries.set({
636
432
  runId,
637
433
  turn,
638
- rejectPath,
639
- `Invalid path: too long or contains non-printing characters`,
640
- 400,
641
- { loopId },
642
- );
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
+ });
643
441
  return {
644
442
  scheme,
645
443
  path: rejectPath,
646
444
  body: "",
647
445
  attributes: {},
648
- status: 400,
446
+ state: "failed",
447
+ outcome: "validation",
649
448
  resultPath: rejectPath,
650
449
  };
651
450
  }
652
451
  const target = rawTarget;
653
- const resultPath = await this.#knownStore.dedup(
654
- runId,
655
- scheme,
656
- target,
657
- turn,
658
- );
452
+ const resultPath = await this.#entries.logPath(runId, turn, scheme, target);
659
453
 
660
454
  // Pass parsed command fields through as attributes
661
455
  const { name: _, ...attributes } = cmd;
662
456
  if (cmd.path) attributes.path = target;
663
457
 
664
- 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;
665
464
 
666
465
  // Filter: plugins can validate/transform before recording
667
466
  const filtered = await this.#hooks.entry.recording.filter(
668
- { scheme, path: resultPath, body, attributes, status: 200 },
669
- { 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 },
670
476
  );
671
- if (filtered.status >= 400) return filtered;
477
+ if (filtered.state === "failed" || filtered.state === "cancelled") {
478
+ return filtered;
479
+ }
672
480
 
673
481
  return {
674
482
  scheme: filtered.scheme,
675
483
  path: filtered.path,
676
484
  body: filtered.body,
677
485
  attributes: filtered.attributes,
678
- status: 200,
486
+ state: "resolved",
679
487
  resultPath: filtered.path,
680
488
  };
681
489
  }