@possumtech/rummy 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.env.example +2 -1
  2. package/PLUGINS.md +1 -1
  3. package/SPEC.md +181 -38
  4. package/migrations/001_initial_schema.sql +1 -1
  5. package/package.json +7 -3
  6. package/service.js +5 -3
  7. package/src/agent/AgentLoop.js +182 -136
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +28 -85
  10. package/src/agent/ResponseHealer.js +65 -31
  11. package/src/agent/TurnExecutor.js +326 -181
  12. package/src/agent/XmlParser.js +5 -2
  13. package/src/agent/known_store.sql +48 -0
  14. package/src/agent/tokens.js +1 -0
  15. package/src/agent/turns.sql +5 -0
  16. package/src/hooks/HookRegistry.js +7 -0
  17. package/src/hooks/Hooks.js +1 -4
  18. package/src/hooks/ToolRegistry.js +2 -8
  19. package/src/plugins/budget/README.md +2 -14
  20. package/src/plugins/budget/budget.js +15 -39
  21. package/src/plugins/cp/cp.js +1 -1
  22. package/src/plugins/cp/cpDoc.js +1 -1
  23. package/src/plugins/get/get.js +71 -1
  24. package/src/plugins/get/getDoc.js +14 -4
  25. package/src/plugins/hedberg/matcher.js +10 -29
  26. package/src/plugins/instructions/preamble.md +16 -6
  27. package/src/plugins/known/known.js +4 -10
  28. package/src/plugins/known/knownDoc.js +15 -14
  29. package/src/plugins/mv/mv.js +18 -1
  30. package/src/plugins/mv/mvDoc.js +15 -1
  31. package/src/plugins/{current → performed}/README.md +4 -3
  32. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  33. package/src/plugins/previous/README.md +2 -1
  34. package/src/plugins/previous/previous.js +31 -25
  35. package/src/plugins/progress/README.md +1 -2
  36. package/src/plugins/progress/progress.js +15 -29
  37. package/src/plugins/prompt/prompt.js +0 -7
  38. package/src/plugins/rm/rm.js +27 -15
  39. package/src/plugins/rm/rmDoc.js +3 -3
  40. package/src/plugins/set/set.js +55 -19
  41. package/src/plugins/set/setDoc.js +6 -2
  42. package/src/plugins/telemetry/telemetry.js +14 -9
  43. package/src/plugins/unknown/README.md +2 -1
  44. package/src/plugins/unknown/unknown.js +5 -4
  45. package/src/server/ClientConnection.js +59 -45
  46. package/src/sql/v_model_context.sql +3 -13
  47. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -19,6 +19,68 @@ export default class TurnExecutor {
19
19
  this.#knownStore = knownStore;
20
20
  }
21
21
 
22
+ /**
23
+ * Rebuild turn_context from v_model_context, then assemble messages.
24
+ * Called at turn start and again after any fidelity demotion within the turn.
25
+ */
26
+ async #materializeTurnContext({
27
+ runId,
28
+ loopId,
29
+ turn,
30
+ systemPrompt,
31
+ mode,
32
+ toolSet,
33
+ contextSize,
34
+ demoted,
35
+ }) {
36
+ await this.#db.clear_turn_context.run({ run_id: runId, turn });
37
+ const viewRows = await this.#db.get_model_context.all({ run_id: runId });
38
+ for (const row of viewRows) {
39
+ const scheme = row.scheme || "file";
40
+ const projectedBody = await this.#hooks.tools.view(scheme, {
41
+ path: row.path,
42
+ scheme,
43
+ body: row.body,
44
+ attributes: row.attributes ? JSON.parse(row.attributes) : null,
45
+ fidelity: row.fidelity,
46
+ category: row.category,
47
+ });
48
+ await this.#db.insert_turn_context.run({
49
+ run_id: runId,
50
+ loop_id: loopId,
51
+ turn,
52
+ ordinal: row.ordinal,
53
+ path: row.path,
54
+ fidelity: row.fidelity,
55
+ status: row.status,
56
+ body: projectedBody ?? "",
57
+ tokens: countTokens(projectedBody ?? ""),
58
+ attributes: row.attributes,
59
+ category: row.category,
60
+ source_turn: row.turn,
61
+ });
62
+ }
63
+ const rows = await this.#db.get_turn_context.all({ run_id: runId, turn });
64
+ const lastCtx = await this.#db.get_last_context_tokens.get({
65
+ run_id: runId,
66
+ });
67
+ const lastContextTokens = lastCtx?.context_tokens ?? 0;
68
+ const messages = await ContextAssembler.assembleFromTurnContext(
69
+ rows,
70
+ {
71
+ type: mode,
72
+ systemPrompt,
73
+ contextSize,
74
+ demoted,
75
+ toolSet,
76
+ lastContextTokens,
77
+ turn,
78
+ },
79
+ this.#hooks,
80
+ );
81
+ return { rows, messages, lastContextTokens };
82
+ }
83
+
22
84
  async execute({
23
85
  mode,
24
86
  project,
@@ -28,12 +90,25 @@ export default class TurnExecutor {
28
90
  currentLoopId,
29
91
  requestedModel,
30
92
  loopPrompt,
93
+ loopIteration,
31
94
  noRepo,
32
95
  toolSet,
96
+ inRecovery = false,
33
97
  contextSize,
34
98
  options,
35
99
  signal,
36
100
  }) {
101
+ const RECOVERY_EXCLUDED = new Set([
102
+ "sh",
103
+ "env",
104
+ "search",
105
+ "ask_user",
106
+ "set",
107
+ ]);
108
+ const effectiveToolSet = inRecovery
109
+ ? new Set([...toolSet].filter((t) => !RECOVERY_EXCLUDED.has(t)))
110
+ : toolSet;
111
+
37
112
  const turn = await this.#knownStore.nextTurn(currentRunId);
38
113
 
39
114
  const turnRow = await this.#db.create_turn.get({
@@ -73,7 +148,7 @@ export default class TurnExecutor {
73
148
  loopId: currentLoopId,
74
149
  turnId: turnRow.id,
75
150
  noRepo,
76
- toolSet,
151
+ toolSet: effectiveToolSet,
77
152
  contextSize,
78
153
  systemPrompt: null,
79
154
  loopPrompt,
@@ -85,6 +160,7 @@ export default class TurnExecutor {
85
160
  mode,
86
161
  prompt: loopPrompt,
87
162
  isContinuation: options?.isContinuation,
163
+ loopIteration,
88
164
  });
89
165
 
90
166
  await this.#hooks.processTurn(rummy);
@@ -111,43 +187,23 @@ export default class TurnExecutor {
111
187
  });
112
188
 
113
189
  // Materialize turn_context: VIEW rows projected through tools
114
- await this.#db.clear_turn_context.run({ run_id: currentRunId, turn });
115
- const viewRows = await this.#db.get_model_context.all({
116
- run_id: currentRunId,
117
- });
118
- for (const row of viewRows) {
119
- const scheme = row.scheme || "file";
120
- const projectedBody = await this.#hooks.tools.view(scheme, {
121
- path: row.path,
122
- scheme,
123
- body: row.body,
124
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
125
- fidelity: row.fidelity,
126
- category: row.category,
127
- });
128
-
129
- await this.#db.insert_turn_context.run({
130
- run_id: currentRunId,
131
- loop_id: currentLoopId,
190
+ const demoted = [];
191
+ let { rows, messages, lastContextTokens } =
192
+ await this.#materializeTurnContext({
193
+ runId: currentRunId,
194
+ loopId: currentLoopId,
132
195
  turn,
133
- ordinal: row.ordinal,
134
- path: row.path,
135
- fidelity: row.fidelity,
136
- status: row.status,
137
- body: projectedBody ?? "",
138
- tokens: countTokens(projectedBody ?? ""),
139
- attributes: row.attributes,
140
- category: row.category,
141
- source_turn: row.turn,
196
+ systemPrompt,
197
+ mode,
198
+ toolSet: effectiveToolSet,
199
+ contextSize,
200
+ demoted,
142
201
  });
143
- }
144
-
145
- const demoted = [];
146
202
 
147
203
  await this.#hooks.context.materialized.emit({
148
204
  runId: currentRunId,
149
205
  turn,
150
- rowCount: viewRows.length,
206
+ rowCount: rows.length,
151
207
  });
152
208
 
153
209
  await this.#hooks.run.progress.emit({
@@ -157,50 +213,75 @@ export default class TurnExecutor {
157
213
  status: "thinking",
158
214
  });
159
215
 
160
- let rows = await this.#db.get_turn_context.all({
161
- run_id: currentRunId,
162
- turn,
163
- });
164
- const lastCtx = await this.#db.get_last_context_tokens.get({
165
- run_id: currentRunId,
166
- });
167
- const lastContextTokens = lastCtx?.context_tokens ?? 0;
168
-
169
- let messages = await ContextAssembler.assembleFromTurnContext(
170
- rows,
171
- {
172
- type: mode,
173
- systemPrompt,
174
- contextSize,
175
- demoted,
176
- toolSet,
177
- lastContextTokens,
178
- },
179
- this.#hooks,
180
- );
181
-
182
216
  const budgetResult = await this.#hooks.budget.enforce({
183
217
  contextSize,
184
218
  messages,
185
219
  rows,
220
+ lastPromptTokens: lastContextTokens,
186
221
  });
187
222
  messages = budgetResult.messages;
188
223
  rows = budgetResult.rows;
189
- const assembledTokens =
224
+ let assembledTokens =
190
225
  budgetResult.assembledTokens ??
191
226
  messages.reduce((sum, m) => sum + countTokens(m.content), 0);
192
227
 
193
- // Budget overflow — return 413 to caller without calling LLM.
194
- // Panic mode suppresses this — the model must run to free space.
195
- if (budgetResult.status === 413 && mode !== "panic") {
196
- return {
197
- turn,
198
- turnId: turnRow.id,
199
- status: 413,
200
- assembledTokens,
201
- contextSize,
202
- overflow: budgetResult.overflow,
203
- };
228
+ if (budgetResult.status === 413) {
229
+ if (loopIteration === 1) {
230
+ // Prompt Demotion: first-turn overflow demote incoming prompt to summary
231
+ const promptRow = rows.findLast(
232
+ (r) => r.category === "prompt" && r.scheme === "prompt",
233
+ );
234
+ if (promptRow) {
235
+ await this.#knownStore.setFidelity(
236
+ currentRunId,
237
+ promptRow.path,
238
+ "summary",
239
+ );
240
+ }
241
+ const reMat = await this.#materializeTurnContext({
242
+ runId: currentRunId,
243
+ loopId: currentLoopId,
244
+ turn,
245
+ systemPrompt,
246
+ mode,
247
+ toolSet: effectiveToolSet,
248
+ contextSize,
249
+ demoted,
250
+ });
251
+ rows = reMat.rows;
252
+ messages = reMat.messages;
253
+ const recheck = await this.#hooks.budget.enforce({
254
+ contextSize,
255
+ messages,
256
+ rows,
257
+ lastPromptTokens: reMat.lastContextTokens,
258
+ });
259
+ messages = recheck.messages;
260
+ rows = recheck.rows;
261
+ assembledTokens =
262
+ recheck.assembledTokens ??
263
+ messages.reduce((sum, m) => sum + countTokens(m.content), 0);
264
+ if (recheck.status === 413) {
265
+ return {
266
+ turn,
267
+ turnId: turnRow.id,
268
+ status: 413,
269
+ assembledTokens,
270
+ contextSize,
271
+ overflow: recheck.overflow,
272
+ };
273
+ }
274
+ } else {
275
+ // Base context too large even without new prompt — genuine failure
276
+ return {
277
+ turn,
278
+ turnId: turnRow.id,
279
+ status: 413,
280
+ assembledTokens,
281
+ contextSize,
282
+ overflow: budgetResult.overflow,
283
+ };
284
+ }
204
285
  }
205
286
 
206
287
  const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
@@ -318,104 +399,164 @@ export default class TurnExecutor {
318
399
  }
319
400
 
320
401
  // --- PHASE 2: DISPATCH ---
321
- // Budget plugin activates the guard on the store for dispatch.
322
- const guard = this.#hooks.budget.activate(
323
- this.#knownStore,
324
- contextSize,
325
- assembledTokens,
326
- );
327
- const { BudgetExceeded } = this.#hooks.budget;
328
-
329
402
  let hasErrors = false;
330
403
  let hasProposed = false;
331
404
  let abortAfter = null;
332
405
  const dispatched = [...lifecycle];
333
406
 
334
- try {
335
- // Lifecycle signals first always dispatched, never aborted.
336
- for (const entry of lifecycle) {
337
- await this.#hooks.tool.before.emit({ entry, rummy });
338
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
339
- await this.#hooks.tool.after.emit({ entry, rummy });
340
- await this.#hooks.entry.created.emit(entry);
407
+ // Lifecycle signals first — always dispatched, never aborted.
408
+ for (const entry of lifecycle) {
409
+ await this.#hooks.tool.before.emit({ entry, rummy });
410
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
411
+ await this.#hooks.tool.after.emit({ entry, rummy });
412
+ await this.#hooks.entry.created.emit(entry);
413
+ }
414
+
415
+ for (const entry of actions) {
416
+ if (abortAfter) {
417
+ const errorMsg = `Aborted — preceding <${abortAfter}> requires resolution.`;
418
+ await this.#knownStore.upsert(
419
+ currentRunId,
420
+ turn,
421
+ entry.resultPath || entry.path,
422
+ errorMsg,
423
+ 409,
424
+ { attributes: { error: errorMsg }, loopId: currentLoopId },
425
+ );
426
+ hasErrors = true;
427
+ continue;
341
428
  }
342
429
 
343
- for (const entry of actions) {
344
- if (abortAfter || guard.isTripped) {
345
- await this.#knownStore.upsert(
346
- currentRunId,
347
- turn,
348
- entry.resultPath || entry.path,
349
- "",
350
- guard.isTripped ? 413 : 409,
351
- {
352
- attributes: {
353
- error: guard.isTripped
354
- ? `Budget exceeded by <${guard.tripSource}>.`
355
- : `Aborted — preceding <${abortAfter}> requires resolution.`,
356
- },
357
- loopId: currentLoopId,
358
- },
359
- );
360
- hasErrors = true;
361
- continue;
362
- }
430
+ await this.#hooks.tool.before.emit({ entry, rummy });
431
+ await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
432
+ await this.#hooks.tool.after.emit({ entry, rummy });
433
+ await this.#hooks.entry.created.emit(entry);
434
+ dispatched.push(entry);
363
435
 
364
- try {
365
- await this.#hooks.tool.before.emit({ entry, rummy });
366
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
367
- await this.#hooks.tool.after.emit({ entry, rummy });
368
- await this.#hooks.entry.created.emit(entry);
369
- dispatched.push(entry);
370
- } catch (err) {
371
- if (err instanceof BudgetExceeded) {
372
- guard.trip(entry.scheme);
373
- await this.#knownStore.upsert(
374
- currentRunId,
375
- turn,
376
- entry.resultPath || entry.path,
377
- `Budget exceeded: ${err.requested} tokens requested, ${err.remaining} remaining.`,
378
- 413,
379
- { attributes: { error: err.message }, loopId: currentLoopId },
380
- );
381
- hasErrors = true;
382
- abortAfter = entry.scheme;
383
- continue;
384
- }
385
- throw err;
386
- }
436
+ const row = await this.#db.get_entry_state.get({
437
+ run_id: currentRunId,
438
+ path: entry.resultPath || entry.path,
439
+ });
440
+ if (row?.status === 202) {
441
+ hasProposed = true;
442
+ abortAfter = entry.scheme;
443
+ } else if (row?.status >= 400) {
444
+ hasErrors = true;
445
+ abortAfter = entry.scheme;
446
+ }
447
+ }
448
+
449
+ // Materialize proposals only if we dispatched actions
450
+ if (!abortAfter || hasProposed) {
451
+ await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
452
+ }
387
453
 
454
+ // Recheck after materialization (set handler may create proposals)
455
+ if (!hasProposed && !hasErrors) {
456
+ for (const entry of actions) {
388
457
  const row = await this.#db.get_entry_state.get({
389
458
  run_id: currentRunId,
390
459
  path: entry.resultPath || entry.path,
391
460
  });
392
- if (row?.status === 202) {
393
- hasProposed = true;
394
- abortAfter = entry.scheme;
395
- } else if (row?.status >= 400) {
396
- hasErrors = true;
397
- abortAfter = entry.scheme;
398
- }
461
+ if (row?.status === 202) hasProposed = true;
462
+ if (row?.status >= 400) hasErrors = true;
399
463
  }
464
+ }
400
465
 
401
- // Materialize proposals only if we dispatched actions
402
- if (!abortAfter || hasProposed) {
403
- await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
404
- }
466
+ // Turn Demotion: if end-of-turn context exceeds ceiling, demote this
467
+ // turn's data entries and the incoming prompt to summary, then force a
468
+ // budget recovery phase before continuing.
469
+ let budgetRecovery = null;
470
+ // Use actual prompt_tokens from this turn's LLM response as the ground-truth
471
+ // token count for post-turn budget checks — more accurate than the estimate.
472
+ const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
473
+ if (contextSize) {
474
+ const postMat = await this.#materializeTurnContext({
475
+ runId: currentRunId,
476
+ loopId: currentLoopId,
477
+ turn,
478
+ systemPrompt,
479
+ mode,
480
+ toolSet: effectiveToolSet,
481
+ contextSize,
482
+ demoted,
483
+ });
484
+ const postBudget = await this.#hooks.budget.enforce({
485
+ contextSize,
486
+ messages: postMat.messages,
487
+ rows: postMat.rows,
488
+ lastPromptTokens: currentPromptTokens,
489
+ });
490
+ if (postBudget.status === 413) {
491
+ // Demote this turn's data entries.
492
+ const demotedEntries = await this.#db.demote_turn_data_entries.all({
493
+ run_id: currentRunId,
494
+ turn,
495
+ });
496
+ const paths = demotedEntries.map((r) => r.path).join(", ");
405
497
 
406
- // Recheck after materialization (set handler may create proposals)
407
- if (!hasProposed && !hasErrors) {
408
- for (const entry of actions) {
409
- const row = await this.#db.get_entry_state.get({
410
- run_id: currentRunId,
411
- path: entry.resultPath || entry.path,
412
- });
413
- if (row?.status === 202) hasProposed = true;
414
- if (row?.status >= 400) hasErrors = true;
498
+ // Also summarize the prompt forces the model to earn it back.
499
+ const promptRow = postMat.rows.find((r) => r.scheme === "prompt");
500
+ if (promptRow) {
501
+ await this.#knownStore.setFidelity(
502
+ currentRunId,
503
+ promptRow.path,
504
+ "summary",
505
+ );
415
506
  }
507
+
508
+ // Re-materialize after both demotions for accurate token count.
509
+ const recoveryMat = await this.#materializeTurnContext({
510
+ runId: currentRunId,
511
+ loopId: currentLoopId,
512
+ turn,
513
+ systemPrompt,
514
+ mode,
515
+ toolSet: effectiveToolSet,
516
+ contextSize,
517
+ demoted,
518
+ });
519
+ const recoveryBudget = await this.#hooks.budget.enforce({
520
+ contextSize,
521
+ messages: recoveryMat.messages,
522
+ rows: recoveryMat.rows,
523
+ lastPromptTokens: currentPromptTokens,
524
+ });
525
+ const safeLevel = Math.floor(contextSize * 0.9);
526
+ const tokensToFree = Math.max(
527
+ 0,
528
+ recoveryBudget.assembledTokens - safeLevel,
529
+ );
530
+
531
+ const promptLine =
532
+ tokensToFree > 0
533
+ ? `Info: Prompt auto-summarized. Full prompt restores automatically when you free ${tokensToFree} tokens.`
534
+ : "Info: Prompt auto-summarized. It will restore automatically.";
535
+ const body = [
536
+ "Error 413: Context Size Exceeded",
537
+ "",
538
+ "Required: YOU MUST demote larger and/or less relevant items to optimize your context.",
539
+ `Info: ${paths} have been automatically summarized to avoid overflow.`,
540
+ promptLine,
541
+ "Info: YOU MAY use bulk patterns to demote and promote entries by pattern.",
542
+ "Info: Well-designed paths and summaries improve context management.",
543
+ 'Example: <set path="known://people/*" fidelity="summary"/>',
544
+ ].join("\n");
545
+
546
+ await this.#knownStore.upsert(
547
+ currentRunId,
548
+ turn,
549
+ `budget://${currentLoopId}/${turn}`,
550
+ body,
551
+ 413,
552
+ { loopId: currentLoopId },
553
+ );
554
+
555
+ budgetRecovery = {
556
+ target: safeLevel,
557
+ promptPath: promptRow?.path ?? null,
558
+ };
416
559
  }
417
- } finally {
418
- this.#hooks.budget.deactivate(this.#knownStore);
419
560
  }
420
561
 
421
562
  // Lifecycle signals are always available — never 409'd.
@@ -433,6 +574,15 @@ export default class TurnExecutor {
433
574
  console.warn(
434
575
  "[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
435
576
  );
577
+ // Mark the recorded summarize entry as 409 so the model sees it was rejected
578
+ if (summaryEntry?.path) {
579
+ await this.#knownStore.resolve(
580
+ currentRunId,
581
+ summaryEntry.path,
582
+ 409,
583
+ "Overridden — actions in this turn failed. Use <update/> until resolved.",
584
+ );
585
+ }
436
586
  updateText = summaryText;
437
587
  summaryText = null;
438
588
  }
@@ -484,12 +634,11 @@ export default class TurnExecutor {
484
634
  flags,
485
635
  model: result.model || requestedModel,
486
636
  modelAlias: requestedModel,
487
- temperature:
488
- options?.temperature ??
489
- Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
637
+ temperature: options?.temperature,
490
638
  contextSize,
491
639
  assembledTokens,
492
640
  usage: result.usage,
641
+ budgetRecovery,
493
642
  };
494
643
 
495
644
  await this.#hooks.turn.completed.emit(turnResult);
@@ -502,8 +651,7 @@ export default class TurnExecutor {
502
651
  * Returns the recorded entry descriptor, or null if rejected/skipped.
503
652
  */
504
653
  async #record(runId, loopId, turn, mode, cmd) {
505
- // Mode enforcement — reject prohibited commands in ask/panic mode
506
- if (mode === "ask" || mode === "panic") {
654
+ if (mode === "ask") {
507
655
  if (cmd.name === "sh") {
508
656
  console.warn("[RUMMY] Rejected <sh> in ask mode");
509
657
  return null;
@@ -581,6 +729,31 @@ export default class TurnExecutor {
581
729
  }
582
730
 
583
731
  const rawTarget = cmd.path || cmd.command || cmd.question || "";
732
+ // Reject paths that are likely reasoning bleed — too long or contain non-printing chars
733
+ if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
734
+ const rejectPath = await this.#knownStore.dedup(
735
+ runId,
736
+ scheme,
737
+ `${scheme}://invalid`,
738
+ turn,
739
+ );
740
+ await this.#knownStore.upsert(
741
+ runId,
742
+ turn,
743
+ rejectPath,
744
+ `Invalid path: too long or contains non-printing characters`,
745
+ 400,
746
+ { loopId },
747
+ );
748
+ return {
749
+ scheme,
750
+ path: rejectPath,
751
+ body: "",
752
+ attributes: {},
753
+ status: 400,
754
+ resultPath: rejectPath,
755
+ };
756
+ }
584
757
  const target = rawTarget;
585
758
  const resultPath = await this.#knownStore.dedup(
586
759
  runId,
@@ -648,6 +821,7 @@ export default class TurnExecutor {
648
821
  cmd.body || existing[0].body,
649
822
  200,
650
823
  {
824
+ attributes,
651
825
  loopId,
652
826
  },
653
827
  );
@@ -660,6 +834,7 @@ export default class TurnExecutor {
660
834
  };
661
835
  }
662
836
  await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
837
+ attributes,
663
838
  loopId,
664
839
  });
665
840
  return {
@@ -702,34 +877,4 @@ export default class TurnExecutor {
702
877
  resultPath: filtered.path,
703
878
  };
704
879
  }
705
-
706
- async #rematerialize(runId, loopId, turn) {
707
- await this.#db.clear_turn_context.run({ run_id: runId, turn });
708
- const viewRows = await this.#db.get_model_context.all({ run_id: runId });
709
- for (const row of viewRows) {
710
- const scheme = row.scheme || "file";
711
- const projectedBody = await this.#hooks.tools.view(scheme, {
712
- path: row.path,
713
- scheme,
714
- body: row.body,
715
- attributes: row.attributes ? JSON.parse(row.attributes) : null,
716
- fidelity: row.fidelity,
717
- category: row.category,
718
- });
719
- await this.#db.insert_turn_context.run({
720
- run_id: runId,
721
- loop_id: loopId,
722
- turn,
723
- ordinal: row.ordinal,
724
- path: row.path,
725
- fidelity: row.fidelity,
726
- status: row.status,
727
- body: projectedBody ?? "",
728
- tokens: countTokens(projectedBody ?? ""),
729
- attributes: row.attributes,
730
- category: row.category,
731
- source_turn: row.turn,
732
- });
733
- }
734
- }
735
880
  }
@@ -4,7 +4,7 @@ import { normalizeAttrs, parseJsonEdit } from "../plugins/hedberg/normalize.js";
4
4
  import { parseSed } from "../plugins/hedberg/sed.js";
5
5
 
6
6
  const STORE_TOOLS = new Set(["get", "rm", "set", "mv", "cp", "search"]);
7
- const ALL_TOOLS = new Set([
7
+ export const ALL_TOOLS = new Set([
8
8
  ...STORE_TOOLS,
9
9
  "known",
10
10
  "sh",
@@ -13,6 +13,9 @@ const ALL_TOOLS = new Set([
13
13
  "summarize",
14
14
  "update",
15
15
  "unknown",
16
+ "think",
17
+ "thought",
18
+ "mcp",
16
19
  ]);
17
20
 
18
21
  /**
@@ -100,7 +103,7 @@ function resolveCommand(name, attrs, rawBody) {
100
103
  if (name === "known") {
101
104
  const body = trimmed || a.body || "";
102
105
  const path = a.path || null;
103
- return { name, path, body };
106
+ return { name, ...a, path, body };
104
107
  }
105
108
 
106
109
  if (name === "get" || name === "rm") {