@possumtech/rummy 0.3.1 → 0.4.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 (46) hide show
  1. package/.env.example +11 -0
  2. package/README.md +5 -1
  3. package/SPEC.md +31 -17
  4. package/migrations/001_initial_schema.sql +2 -3
  5. package/package.json +1 -1
  6. package/src/agent/AgentLoop.js +50 -151
  7. package/src/agent/KnownStore.js +15 -7
  8. package/src/agent/TurnExecutor.js +75 -318
  9. package/src/agent/XmlParser.js +25 -4
  10. package/src/agent/known_queries.sql +1 -1
  11. package/src/agent/known_store.sql +11 -61
  12. package/src/agent/runs.sql +2 -2
  13. package/src/hooks/Hooks.js +1 -0
  14. package/src/hooks/ToolRegistry.js +6 -5
  15. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  16. package/src/plugins/budget/README.md +26 -18
  17. package/src/plugins/budget/budget.js +60 -3
  18. package/src/plugins/budget/recovery.js +47 -0
  19. package/src/plugins/cp/cpDoc.js +4 -9
  20. package/src/plugins/env/envDoc.js +3 -8
  21. package/src/plugins/get/get.js +2 -4
  22. package/src/plugins/get/getDoc.js +11 -18
  23. package/src/plugins/helpers.js +2 -2
  24. package/src/plugins/instructions/instructions.js +3 -2
  25. package/src/plugins/instructions/preamble.md +27 -16
  26. package/src/plugins/known/known.js +63 -8
  27. package/src/plugins/known/knownDoc.js +10 -14
  28. package/src/plugins/mv/mvDoc.js +6 -21
  29. package/src/plugins/policy/policy.js +47 -0
  30. package/src/plugins/progress/progress.js +9 -45
  31. package/src/plugins/prompt/prompt.js +10 -1
  32. package/src/plugins/rm/rmDoc.js +5 -10
  33. package/src/plugins/rpc/rpc.js +3 -1
  34. package/src/plugins/set/set.js +82 -85
  35. package/src/plugins/set/setDoc.js +28 -41
  36. package/src/plugins/sh/shDoc.js +2 -7
  37. package/src/plugins/summarize/summarize.js +7 -0
  38. package/src/plugins/summarize/summarizeDoc.js +6 -11
  39. package/src/plugins/think/think.js +12 -0
  40. package/src/plugins/think/thinkDoc.js +18 -0
  41. package/src/plugins/unknown/unknown.js +21 -0
  42. package/src/plugins/unknown/unknownDoc.js +9 -14
  43. package/src/plugins/update/update.js +7 -0
  44. package/src/plugins/update/updateDoc.js +6 -11
  45. package/src/server/ClientConnection.js +11 -1
  46. package/src/sql/v_model_context.sql +4 -4
package/.env.example CHANGED
@@ -17,6 +17,7 @@ RUMMY_MMAP_MB=0
17
17
 
18
18
  # Agent Loop Limits
19
19
  RUMMY_MAX_TURNS=99
20
+ RUMMY_MAX_COMMANDS=15
20
21
  RUMMY_MAX_UNKNOWN_WARNINGS=3
21
22
  RUMMY_MAX_STALLS=3
22
23
  RUMMY_MIN_CYCLES=3
@@ -34,6 +35,16 @@ RUMMY_FETCH_TIMEOUT=300000
34
35
  # Debug
35
36
  # RUMMY_DEBUG=true
36
37
 
38
+ # Think tag: 1 = model uses <think> tags for reasoning (default)
39
+ # 0 = disabled, model reasons via API reasoning_content field only
40
+ RUMMY_THINK=1
41
+
42
+ # Budget
43
+ # Fraction of context window used as ceiling. 0.9 = 90%, 10% reserved as headroom.
44
+ RUMMY_BUDGET_CEILING=0.9
45
+ # Maximum tokens per known entry. Entries exceeding this are rejected with 413.
46
+ RUMMY_MAX_ENTRY_TOKENS=512
47
+
37
48
  # Token Estimation
38
49
  # Characters per token. Lower = more conservative (fewer tokens per character).
39
50
  # Default 2. Set to 1 for worst-case (1 token per character).
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # RUMMY: Relational Unknowns Memory Management Yoke
2
2
 
3
- Rummy is the only LLM agent service inspired by and dedicated to the memory of former Secretary of State Donald "Rummy" Rumsfeld. Our unique fusion of apophatic and hedbergian engineering strategies yields more accurate and efficient results than any other agent. Our client/server and plugin architecture integrates it into more workflows than any other agent. It's also more flexible and lean than any other agent. Our dynamic cache management, model hot-swapping, and flexible router interface make it more affordable than any other agent.
3
+ Rummy is the only LLM agent service inspired by and dedicated to the memory of former Secretary of Defense Donald "Rummy" Rumsfeld. Our unique fusion of apophatic and hedbergian engineering strategies yields more accurate and efficient results than any other agent. Our client/server and plugin architecture integrates it into more workflows than any other agent. It's also more flexible and lean than any other agent. Our dynamic cache management, model hot-swapping, and flexible router interface make it more affordable than any other agent.
4
4
 
5
5
  ## Key Features
6
6
 
@@ -10,6 +10,10 @@ Rummy is the only LLM agent service inspired by and dedicated to the memory of f
10
10
 
11
11
  - **Hedberg:** The interpretation boundary between stochastic model output and deterministic system operations. Models speak in whatever syntax they were trained on — sed regex, SEARCH/REPLACE blocks, escaped characters. Hedberg normalizes all of it. Available to all plugins via `core.hooks.hedberg`.
12
12
 
13
+ - **Folksonomic Memory:** The model organizes its own knowledge into navigable path hierarchies with searchable summary tags. Not RAG — the model builds and curates its own taxonomy using `<known>` entries with paths like `known://project/architecture`.
14
+
15
+ - **Fidelity System:** Every entry has a visibility level: full, summary, index, archive. The model manages its own context by promoting what it needs and demoting what it doesn't. Budget enforcement catches overflow post-dispatch — tools run uninterrupted, demotion happens after.
16
+
13
17
  - **Plugin Architecture:** Every `<tag>` the model sees is a plugin. Every scheme is registered by its owner. The prompt itself is assembled from plugins. Drop a directory into `~/.rummy/plugins/` or install via npm. See [PLUGINS.md](PLUGINS.md) for the complete plugin API.
14
18
 
15
19
  - **Symbols Done Right:** Designed with universal language support in mind. Powered by [@possumtech/antlrmap](https://github.com/possumtech/antlrmap).
package/SPEC.md CHANGED
@@ -44,7 +44,7 @@ body, attributes, and state.
44
44
  known_entries (
45
45
  id, run_id, loop_id, turn, path, body, scheme,
46
46
  status INTEGER, fidelity TEXT, hash,
47
- attributes, tokens, tokens_full, refs, write_count,
47
+ attributes, tokens, refs, write_count,
48
48
  created_at, updated_at
49
49
  )
50
50
  ```
@@ -56,10 +56,9 @@ known_entries (
56
56
  | `attributes` | Tag attributes as JSON. Handler-private workspace. `CHECK (json_valid)` |
57
57
  | `scheme` | Generated from path via `schemeOf()`. Drives dispatch and view routing |
58
58
  | `status` | HTTP status code (200, 202, 400, 413, etc.) |
59
- | `fidelity` | Visibility level: full, summary, index, archive |
59
+ | `fidelity` | Visibility level: full, summary, archive |
60
60
  | `hash` | SHA-256 for file change detection |
61
- | `tokens` | Display-only token count at current fidelity. NEVER used for budget. |
62
- | `tokens_full` | Cost of raw body at full fidelity |
61
+ | `tokens` | Full-body token cost. Never changes on demotion/promotion. |
63
62
  | `turn` | Freshness — when was this entry last touched |
64
63
 
65
64
  ### 1.2 Schemes, Status & Fidelity
@@ -211,8 +210,8 @@ object is the same shape at every tier.
211
210
  Model tier restrictions enforced by unified `resolveForLoop(mode, flags)`.
212
211
  Ask mode excludes `sh`. Flags: `noInteraction` excludes `ask_user`,
213
212
  `noWeb` excludes `search`, `noProposals` excludes `ask_user`/`env`/`sh`.
214
- 13 model tools: get, set, known, unknown, env, sh, rm, cp, mv, search,
215
- summarize, update, ask_user.
213
+ 14 model tools: think, unknown, known, get, set, env, sh, rm, cp, mv,
214
+ ask_user, update, summarize, search.
216
215
  Client tier requires project init. Plugin tier has no restrictions.
217
216
 
218
217
  ### 3.2 Dispatch Path
@@ -225,13 +224,28 @@ Client: JSON-RPC → { method, params } → #record() → dispatch(scheme, en
225
224
  Plugin: rummy.rm({ path }) → #record() → dispatch(scheme, entry, rummy)
226
225
  ```
227
226
 
228
- **Lifecycle/action split:** Commands are classified as lifecycle signals
229
- (`summarize`, `update`, `unknown`, `known`) or action commands (everything
230
- else). Lifecycle signals always dispatch they are state declarations that
231
- cannot be 409'd by sequential dispatch. Action commands dispatch sequentially;
232
- a 202 proposal or error aborts subsequent actions. If the model sends
233
- `<summarize>` but actions in the same turn failed, the summarize is
234
- overridden to an update (the model's assertion that it's done is false).
227
+ **Tool dispatch:** Commands are dispatched sequentially in the order
228
+ the model emitted them. Each tool either succeeds (200), fails (400+),
229
+ or proposes (202). On failure, all remaining tools are aborted. On
230
+ proposal, dispatch pauses, a notification is pushed to the client
231
+ (same WebSocket push pattern as `run/progress`), the client resolves
232
+ (accept/reject), and dispatch resumes the proposal becomes 200 or
233
+ 400+ like any other tool. The `ask`/`act` RPC response is only sent
234
+ when all tools have completed. Proposals are NOT batched — each is
235
+ sent and resolved inline during dispatch. The model controls tool
236
+ ordering; the system respects it.
237
+
238
+ If the model sends `<summarize>` but a preceding action in the same
239
+ turn failed, the summarize is overridden to an update (the model's
240
+ assertion that it's done is false). Both `<summarize>` and `<update>`
241
+ present → last signal wins.
242
+
243
+ **Post-dispatch budget check:** After all tools dispatch, the system
244
+ materializes context and checks the budget ceiling. If context exceeds
245
+ the ceiling, Turn Demotion fires — all entries from this turn are
246
+ demoted to summary and a `budget://` entry is written. This is a
247
+ system housekeeping step independent of tool success/failure. The
248
+ tools already ran; their outcomes are settled.
235
249
 
236
250
  ### 3.3 Plugin Convention
237
251
 
@@ -293,7 +307,7 @@ Two messages per turn. System = stable truth. User = active task.
293
307
  [skills/]
294
308
  [/instructions]
295
309
  <knowns>
296
- ...entries sorted by fidelity (index, summary, full), then by scheme
310
+ ...entries sorted by fidelity (summary, full), then by scheme
297
311
  </knowns>
298
312
  <previous>
299
313
  (pre-loop entries, each with turn, status, summary, fidelity, tokens)
@@ -531,7 +545,7 @@ ask_user. `noRepo: true` — no file scanning during panic.
531
545
  `budget.panicPrompt()`: the assembled token count, the target, and
532
546
  the exact number of tokens to free. Turn 2+ receives a continuation
533
547
  prompt. The model uses `<set fidelity="archive">`, `<mv
534
- fidelity="index">`, and similar fidelity operations to free space,
548
+ fidelity="summary">`, and similar fidelity operations to free space,
535
549
  concluding with `<summarize>` when done or `<update>` while working.
536
550
 
537
551
  ---
@@ -660,7 +674,7 @@ simple to powerful — weak models learn from examples 1-2, strong models
660
674
  pick up the pattern from example 3.
661
675
 
662
676
  **Lifecycle continuity.** Examples weave stories across tools. The get
663
- docs end with `<set path="..." fidelity="index"/>`. The known docs
677
+ docs end with `<set path="..." fidelity="summary"/>`. The known docs
664
678
  reference `<get path="known://*">keyword</get>` for recall and
665
679
  `<set path="known://..." archive/>` for archiving. The unknown docs
666
680
  reference `<get/>` for investigation and `<rm/>` for cleanup. A model
@@ -746,7 +760,7 @@ Termination protocol:
746
760
  - `<summarize>` → run terminates
747
761
  - `<summarize>` + failed actions → overridden to `<update>` (continue)
748
762
  - `<update>` → run continues
749
- - Both → update wins (if the model can't decide, it's not done)
763
+ - Both → last signal wins (respects the model's final intent)
750
764
  - Neither + investigation tools → stall counter (RUMMY_MAX_STALLS)
751
765
  - Neither + action-only tools → healed to summarize
752
766
  - Neither + plain text → healed to summarize
@@ -125,12 +125,11 @@ CREATE TABLE IF NOT EXISTS known_entries (
125
125
  , scheme TEXT GENERATED ALWAYS AS (schemeOf(path)) STORED
126
126
  , status INTEGER NOT NULL DEFAULT 200 CHECK (status BETWEEN 100 AND 599)
127
127
  , fidelity TEXT NOT NULL DEFAULT 'full' CHECK (
128
- fidelity IN ('full', 'summary', 'index', 'archive')
128
+ fidelity IN ('full', 'summary', 'archive')
129
129
  )
130
130
  , hash TEXT
131
131
  , attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
132
132
  , tokens INTEGER NOT NULL DEFAULT 0 CHECK (tokens >= 0)
133
- , tokens_full INTEGER NOT NULL DEFAULT 0 CHECK (tokens_full >= 0)
134
133
  , refs INTEGER NOT NULL DEFAULT 0 CHECK (refs >= 0)
135
134
  , write_count INTEGER NOT NULL DEFAULT 1 CHECK (write_count >= 1)
136
135
  , created_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -167,7 +166,7 @@ CREATE TABLE IF NOT EXISTS turn_context (
167
166
  , path TEXT NOT NULL
168
167
  , scheme TEXT GENERATED ALWAYS AS (schemeOf(path)) STORED
169
168
  , status INTEGER NOT NULL DEFAULT 200 CHECK (status BETWEEN 100 AND 599)
170
- , fidelity TEXT NOT NULL CHECK (fidelity IN ('full', 'summary', 'index'))
169
+ , fidelity TEXT NOT NULL CHECK (fidelity IN ('full', 'summary'))
171
170
  , body TEXT NOT NULL DEFAULT ''
172
171
  , tokens INTEGER NOT NULL DEFAULT 0 CHECK (tokens >= 0)
173
172
  , attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@possumtech/rummy",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Relational Unknowns Memory Management Yoke",
5
5
  "keywords": [
6
6
  "llm"
@@ -1,3 +1,4 @@
1
+ import { advanceRecovery } from "../plugins/budget/recovery.js";
1
2
  import KnownStore from "./KnownStore.js";
2
3
  import msg from "./messages.js";
3
4
  import ResponseHealer from "./ResponseHealer.js";
@@ -70,14 +71,15 @@ export default class AgentLoop {
70
71
  const existing = this.#activeRuns.get(existingRun.id);
71
72
  if (existing) existing.abort();
72
73
 
74
+ // Clean up stale proposals from interrupted runs
73
75
  const unresolved = await this.#knownStore.getUnresolved(existingRun.id);
74
- if (unresolved.length > 0) {
75
- return {
76
- runId: existingRun.id,
77
- alias: existingRun.alias,
78
- blocked: true,
79
- proposed: unresolved,
80
- };
76
+ for (const u of unresolved) {
77
+ await this.#knownStore.resolve(
78
+ existingRun.id,
79
+ u.path,
80
+ 499,
81
+ "Stale proposal from interrupted run",
82
+ );
81
83
  }
82
84
  return { runId: existingRun.id, alias: existingRun.alias };
83
85
  }
@@ -125,15 +127,6 @@ export default class AgentLoop {
125
127
  const requestedModel = model;
126
128
 
127
129
  const runInfo = await this.#ensureRun(projectId, model, run, options);
128
- if (runInfo.blocked) {
129
- return {
130
- run: runInfo.alias,
131
- status: 202,
132
- remainingCount: runInfo.proposed.length,
133
- proposed: runInfo.proposed,
134
- };
135
- }
136
-
137
130
  const { runId: currentRunId, alias: currentAlias } = runInfo;
138
131
 
139
132
  const loopSeq = await this.#db.next_loop.get({ run_id: currentRunId });
@@ -222,11 +215,9 @@ export default class AgentLoop {
222
215
 
223
216
  await this.#db.complete_loop.run({
224
217
  id: loop.id,
225
- status: result.status === 202 ? 202 : result.status,
218
+ status: result.status,
226
219
  result: JSON.stringify(result),
227
220
  });
228
-
229
- if (result.status === 202) return result;
230
221
  }
231
222
 
232
223
  const runRow = await this.#db.get_run_by_alias.get({
@@ -282,12 +273,9 @@ export default class AgentLoop {
282
273
  let _lastAssembledTokens = 0;
283
274
  let recovery = null; // { target, promptPath, strikes, lastTokens }
284
275
 
285
- // Demote full logging entries from previous loops to summary before
286
- // they appear in <previous>. General policy: keep <previous> compact.
287
- await this.#knownStore.demotePreviousLoopLogging(
288
- currentRunId,
289
- currentLoopId,
290
- );
276
+ // Previous loop entries stay at full fidelity the model is
277
+ // instructed to summarize and demote them. Budget enforcement
278
+ // catches overflow if the model fails to manage context.
291
279
 
292
280
  // Restore any prompt entries left at summary fidelity by a recovery
293
281
  // phase that was interrupted (server crash, restart). If the full
@@ -347,7 +335,16 @@ export default class AgentLoop {
347
335
  });
348
336
 
349
337
  if (result.status === 413) {
350
- return {
338
+ await this.#db.complete_loop.run({
339
+ id: currentLoopId,
340
+ status: 413,
341
+ result: null,
342
+ });
343
+ await this.#db.update_run_status.run({
344
+ id: currentRunId,
345
+ status: 200,
346
+ });
347
+ const out = {
351
348
  run: currentAlias,
352
349
  status: 413,
353
350
  overflow: result.overflow,
@@ -355,6 +352,8 @@ export default class AgentLoop {
355
352
  contextSize: result.contextSize,
356
353
  turn: result.turn,
357
354
  };
355
+ await hook.completed.emit({ projectId, ...out });
356
+ return out;
358
357
  }
359
358
 
360
359
  _lastAssembledTokens = result.assembledTokens;
@@ -390,8 +389,6 @@ export default class AgentLoop {
390
389
  const unknowns = await this.#db.get_unknowns.all({
391
390
  run_id: currentRunId,
392
391
  });
393
- const unresolved = await this.#knownStore.getUnresolved(currentRunId);
394
-
395
392
  const latestSummary = history
396
393
  .filter((e) => e.status === 200 && e.path?.startsWith("summarize://"))
397
394
  .at(-1);
@@ -400,15 +397,10 @@ export default class AgentLoop {
400
397
  projectId,
401
398
  run: currentAlias,
402
399
  turn: result.turn,
403
- status: unresolved.length > 0 ? 202 : 102,
400
+ status: 102,
404
401
  summary: latestSummary?.body || "",
405
402
  history,
406
403
  unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
407
- proposed: unresolved.map((p) => ({
408
- path: p.path,
409
- type: KnownStore.toolFromPath(p.path) || "unknown",
410
- attributes: p.attributes ? JSON.parse(p.attributes) : null,
411
- })),
412
404
  telemetry: {
413
405
  modelAlias: result.modelAlias,
414
406
  model: result.model,
@@ -433,21 +425,6 @@ export default class AgentLoop {
433
425
  }),
434
426
  },
435
427
  });
436
- if (unresolved.length > 0) {
437
- await this.#db.update_run_status.run({
438
- id: currentRunId,
439
- status: 202,
440
- });
441
- const out = {
442
- run: currentAlias,
443
- status: 202,
444
- turn: result.turn,
445
- proposed: unresolved,
446
- };
447
- await hook.completed.emit({ projectId, ...out });
448
- return out;
449
- }
450
-
451
428
  await this.#hooks.run.step.completed.emit({
452
429
  projectId,
453
430
  run: currentAlias,
@@ -574,6 +551,12 @@ export default class AgentLoop {
574
551
  }
575
552
 
576
553
  if (action === "accept") {
554
+ const projectId = runRow.project_id;
555
+ const project = await this.#db.get_project_by_id.get({
556
+ id: projectId,
557
+ });
558
+ const projectRoot = project?.project_root;
559
+
577
560
  if (path.startsWith("set://") && attrs?.file && attrs?.merge) {
578
561
  const fileBody = await this.#knownStore.getBody(runId, attrs.file);
579
562
  if (fileBody != null) {
@@ -594,12 +577,25 @@ export default class AgentLoop {
594
577
  patched,
595
578
  200,
596
579
  );
580
+ // Write patched content to disk
581
+ if (projectRoot) {
582
+ const { writeFile } = await import("node:fs/promises");
583
+ const { join } = await import("node:path");
584
+ await writeFile(join(projectRoot, attrs.file), patched).catch(
585
+ () => {},
586
+ );
587
+ }
597
588
  }
598
589
  }
599
590
 
600
591
  if (path.startsWith("rm://")) {
601
592
  if (attrs?.path) {
602
593
  await this.#knownStore.remove(runId, attrs.path);
594
+ if (projectRoot) {
595
+ const { unlink } = await import("node:fs/promises");
596
+ const { join } = await import("node:path");
597
+ await unlink(join(projectRoot, attrs.path)).catch(() => {});
598
+ }
603
599
  }
604
600
  }
605
601
 
@@ -615,68 +611,9 @@ export default class AgentLoop {
615
611
  throw new Error(msg("error.resolution_invalid", { action }));
616
612
  }
617
613
 
618
- const unresolved = await this.#knownStore.getUnresolved(runId);
619
- if (unresolved.length > 0) {
620
- return {
621
- run: runAlias,
622
- status: 202,
623
- remainingCount: unresolved.length,
624
- proposed: unresolved,
625
- };
626
- }
627
-
628
- // Scope completion checks to the current loop
629
- const currentLoop = await this.#db.get_current_loop.get({ run_id: runId });
630
- const loopId = currentLoop?.id ?? null;
631
-
632
- if (await this.#knownStore.hasRejections(runId, loopId)) {
633
- if (currentLoop)
634
- await this.#db.complete_loop.run({
635
- id: loopId,
636
- status: 200,
637
- result: null,
638
- });
639
- await this.#db.update_run_status.run({ id: runId, status: 200 });
640
- return { run: runAlias, status: 200 };
641
- }
642
-
643
- const hasSummary = await this.#db.get_latest_summary.get({
644
- run_id: runId,
645
- loop_id: loopId,
646
- });
647
- if (hasSummary?.body) {
648
- if (currentLoop)
649
- await this.#db.complete_loop.run({
650
- id: loopId,
651
- status: 200,
652
- result: null,
653
- });
654
- await this.#db.update_run_status.run({ id: runId, status: 200 });
655
- return { run: runAlias, status: 200 };
656
- }
657
-
658
- // No summary and no rejections in this loop — resume it
659
- const projectId = runRow.project_id;
660
- const project = await this.#db.get_project_by_id.get({ id: projectId });
661
-
662
- const latestPrompt = await this.#db.get_latest_prompt.get({
663
- run_id: runId,
664
- });
665
- const resumeMode = latestPrompt?.attributes
666
- ? JSON.parse(latestPrompt.attributes).mode
667
- : "ask";
668
-
669
- // Re-enqueue the current loop's prompt to continue it
670
- const loopSeq = await this.#db.next_loop.get({ run_id: runId });
671
- await this.#db.enqueue_loop.get({
672
- run_id: runId,
673
- sequence: loopSeq.sequence,
674
- mode: resumeMode,
675
- model: runRow.model,
676
- prompt: "",
677
- config: currentLoop?.config || "{}",
678
- });
679
- return this.#drainQueue(runId, runAlias, projectId, project, {});
614
+ // The dispatch loop is awaiting resolution. This unblocks it.
615
+ // Dispatch continuation is handled by the loop, not here.
616
+ return { run: runAlias, status: 200 };
680
617
  }
681
618
 
682
619
  async #composeResolvedContent(runId, path, _attrs, output) {
@@ -741,43 +678,5 @@ export default class AgentLoop {
741
678
  * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
742
679
  * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
743
680
  */
744
- export function advanceRecovery(recovery, result) {
745
- // Initialise or update recovery state from a new Turn Demotion event.
746
- if (result.budgetRecovery) {
747
- if (!recovery) {
748
- recovery = {
749
- target: result.budgetRecovery.target,
750
- promptPath: result.budgetRecovery.promptPath,
751
- strikes: 0,
752
- lastTokens: result.assembledTokens,
753
- };
754
- } else {
755
- // Re-overflow during recovery: tighten target, don't count as strike.
756
- recovery = {
757
- ...recovery,
758
- target: Math.min(recovery.target, result.budgetRecovery.target),
759
- };
760
- }
761
- }
762
-
763
- if (recovery === null) return { next: null, action: null, promptPath: null };
764
-
765
- const current = result.assembledTokens;
766
-
767
- if (current <= recovery.target) {
768
- return { next: null, action: "restore", promptPath: recovery.promptPath };
769
- }
770
-
771
- const noProgress = current >= recovery.lastTokens && !result.budgetRecovery;
772
- const strikes = noProgress ? recovery.strikes + 1 : 0;
773
-
774
- if (strikes >= 3) {
775
- return { next: null, action: "hard413", promptPath: null };
776
- }
777
-
778
- return {
779
- next: { ...recovery, strikes, lastTokens: current },
780
- action: null,
781
- promptPath: null,
782
- };
783
- }
681
+ // Re-export for backward compatibility with tests
682
+ export { advanceRecovery } from "../plugins/budget/recovery.js";
@@ -5,6 +5,7 @@ export default class KnownStore {
5
5
  #onChanged;
6
6
  #schemes = new Map();
7
7
  #seq = 0;
8
+ #pendingResolutions = new Map();
8
9
 
9
10
  constructor(db, { onChanged } = {}) {
10
11
  this.#db = db;
@@ -225,6 +226,20 @@ export default class KnownStore {
225
226
  body,
226
227
  });
227
228
  this.#emitChanged(runId, normalized, "resolve");
229
+ const key = `${runId}:${normalized}`;
230
+ const resolver = this.#pendingResolutions.get(key);
231
+ if (resolver) {
232
+ this.#pendingResolutions.delete(key);
233
+ resolver();
234
+ }
235
+ }
236
+
237
+ waitForResolution(runId, path) {
238
+ const normalized = KnownStore.normalizePath(path);
239
+ const key = `${runId}:${normalized}`;
240
+ return new Promise((resolve) => {
241
+ this.#pendingResolutions.set(key, resolve);
242
+ });
228
243
  }
229
244
 
230
245
  async restoreSummarizedPrompts(runId) {
@@ -232,13 +247,6 @@ export default class KnownStore {
232
247
  this.#emitChanged(runId, "prompt://batch", "fidelity");
233
248
  }
234
249
 
235
- async demotePreviousLoopLogging(runId, loopId) {
236
- await this.#db.demote_previous_loop_logging.run({
237
- run_id: runId,
238
- loop_id: loopId,
239
- });
240
- this.#emitChanged(runId, "logging://batch", "fidelity");
241
- }
242
250
 
243
251
  async getLog(runId) {
244
252
  return this.#db.get_results.all({ run_id: runId });