@possumtech/rummy 2.0.1 → 2.1.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 (114) hide show
  1. package/.env.example +12 -7
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +305 -28
  6. package/bin/postinstall.js +2 -2
  7. package/bin/rummy.js +2 -2
  8. package/last_run.txt +5617 -0
  9. package/migrations/001_initial_schema.sql +2 -1
  10. package/package.json +6 -2
  11. package/scriptify/cache_probe.js +66 -0
  12. package/scriptify/cache_probe_grok.js +74 -0
  13. package/service.js +22 -11
  14. package/src/agent/AgentLoop.js +33 -139
  15. package/src/agent/ContextAssembler.js +2 -9
  16. package/src/agent/Entries.js +36 -101
  17. package/src/agent/ProjectAgent.js +2 -9
  18. package/src/agent/TurnExecutor.js +45 -83
  19. package/src/agent/XmlParser.js +247 -273
  20. package/src/agent/budget.js +5 -28
  21. package/src/agent/config.js +38 -0
  22. package/src/agent/errors.js +7 -13
  23. package/src/agent/httpStatus.js +1 -19
  24. package/src/agent/known_store.sql +7 -2
  25. package/src/agent/materializeContext.js +12 -17
  26. package/src/agent/pathEncode.js +5 -0
  27. package/src/agent/rummyHome.js +9 -0
  28. package/src/agent/runs.sql +18 -0
  29. package/src/agent/tokens.js +2 -8
  30. package/src/hooks/HookRegistry.js +1 -16
  31. package/src/hooks/Hooks.js +8 -33
  32. package/src/hooks/PluginContext.js +3 -21
  33. package/src/hooks/RpcRegistry.js +1 -4
  34. package/src/hooks/RummyContext.js +2 -16
  35. package/src/hooks/ToolRegistry.js +5 -15
  36. package/src/llm/LlmProvider.js +28 -23
  37. package/src/llm/errors.js +41 -4
  38. package/src/llm/openaiStream.js +125 -0
  39. package/src/llm/retry.js +61 -15
  40. package/src/plugins/budget/budget.js +14 -81
  41. package/src/plugins/cli/README.md +87 -0
  42. package/src/plugins/cli/bin.js +61 -0
  43. package/src/plugins/cli/cli.js +120 -0
  44. package/src/plugins/env/README.md +2 -1
  45. package/src/plugins/env/env.js +4 -6
  46. package/src/plugins/env/envDoc.md +2 -2
  47. package/src/plugins/error/error.js +23 -23
  48. package/src/plugins/file/file.js +2 -22
  49. package/src/plugins/get/get.js +12 -34
  50. package/src/plugins/get/getDoc.md +5 -3
  51. package/src/plugins/hedberg/edits.js +1 -11
  52. package/src/plugins/hedberg/hedberg.js +3 -26
  53. package/src/plugins/hedberg/normalize.js +1 -5
  54. package/src/plugins/hedberg/patterns.js +4 -15
  55. package/src/plugins/hedberg/sed.js +1 -7
  56. package/src/plugins/helpers.js +28 -20
  57. package/src/plugins/index.js +25 -41
  58. package/src/plugins/instructions/README.md +18 -0
  59. package/src/plugins/instructions/instructions.js +13 -76
  60. package/src/plugins/instructions/instructions.md +19 -18
  61. package/src/plugins/instructions/instructions_104.md +5 -4
  62. package/src/plugins/instructions/instructions_105.md +16 -15
  63. package/src/plugins/instructions/instructions_106.md +15 -14
  64. package/src/plugins/instructions/instructions_107.md +13 -6
  65. package/src/plugins/known/README.md +26 -6
  66. package/src/plugins/known/known.js +36 -34
  67. package/src/plugins/log/README.md +2 -2
  68. package/src/plugins/log/log.js +6 -33
  69. package/src/plugins/ollama/ollama.js +50 -66
  70. package/src/plugins/openai/openai.js +26 -44
  71. package/src/plugins/openrouter/openrouter.js +28 -52
  72. package/src/plugins/policy/README.md +8 -2
  73. package/src/plugins/policy/policy.js +8 -21
  74. package/src/plugins/prompt/README.md +22 -0
  75. package/src/plugins/prompt/prompt.js +8 -16
  76. package/src/plugins/rm/rm.js +5 -2
  77. package/src/plugins/rm/rmDoc.md +4 -4
  78. package/src/plugins/rpc/README.md +2 -1
  79. package/src/plugins/rpc/rpc.js +51 -47
  80. package/src/plugins/set/README.md +5 -1
  81. package/src/plugins/set/set.js +23 -33
  82. package/src/plugins/set/setDoc.md +1 -1
  83. package/src/plugins/sh/README.md +2 -1
  84. package/src/plugins/sh/sh.js +5 -11
  85. package/src/plugins/sh/shDoc.md +2 -2
  86. package/src/plugins/stream/README.md +6 -5
  87. package/src/plugins/stream/stream.js +6 -35
  88. package/src/plugins/telemetry/telemetry.js +26 -19
  89. package/src/plugins/think/think.js +4 -7
  90. package/src/plugins/unknown/unknown.js +8 -13
  91. package/src/plugins/update/update.js +36 -35
  92. package/src/plugins/update/updateDoc.md +3 -3
  93. package/src/plugins/xai/xai.js +30 -20
  94. package/src/plugins/yolo/yolo.js +8 -41
  95. package/src/server/ClientConnection.js +17 -47
  96. package/src/server/SocketServer.js +14 -14
  97. package/src/server/protocol.js +1 -10
  98. package/src/sql/functions/slugify.js +5 -7
  99. package/src/sql/v_model_context.sql +4 -11
  100. package/turns/cli_1777462658211/turn_001.txt +772 -0
  101. package/turns/cli_1777462658211/turn_002.txt +606 -0
  102. package/turns/cli_1777462658211/turn_003.txt +667 -0
  103. package/turns/cli_1777462658211/turn_004.txt +297 -0
  104. package/turns/cli_1777462658211/turn_005.txt +301 -0
  105. package/turns/cli_1777462658211/turn_006.txt +262 -0
  106. package/turns/cli_1777465095132/turn_001.txt +715 -0
  107. package/turns/cli_1777465095132/turn_002.txt +236 -0
  108. package/turns/cli_1777465095132/turn_003.txt +287 -0
  109. package/turns/cli_1777465095132/turn_004.txt +694 -0
  110. package/turns/cli_1777465095132/turn_005.txt +422 -0
  111. package/turns/cli_1777465095132/turn_006.txt +365 -0
  112. package/turns/cli_1777465095132/turn_007.txt +885 -0
  113. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  114. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -218,7 +218,8 @@ CREATE TABLE IF NOT EXISTS turn_context (
218
218
  state IN ('proposed', 'streaming', 'resolved', 'failed', 'cancelled')
219
219
  )
220
220
  , outcome TEXT
221
- , visibility TEXT NOT NULL CHECK (visibility IN ('visible', 'summarized'))
221
+ -- 'archived' permitted; see prompt plugin README for the exception.
222
+ , visibility TEXT NOT NULL CHECK (visibility IN ('visible', 'summarized', 'archived'))
222
223
  , body TEXT NOT NULL DEFAULT ''
223
224
  , attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
224
225
  , category TEXT NOT NULL DEFAULT 'logging'
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@possumtech/rummy",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Relational Unknowns Memory Management Yoke",
5
5
  "keywords": [
6
6
  "llm"
7
7
  ],
8
8
  "bin": {
9
- "rummy": "./bin/rummy.js"
9
+ "rummy": "./bin/rummy.js",
10
+ "rummy-cli": "./src/plugins/cli/bin.js"
10
11
  },
11
12
  "publishConfig": {
12
13
  "access": "public"
@@ -51,6 +52,9 @@
51
52
  "test:swe:baseline": "bash -c 'cd test/swe && source .venv/bin/activate && python baseline.py \"$@\"' --",
52
53
  "test:lme:clean": "rm -rf test/lme/results/*/",
53
54
  "test:swe:clean": "rm -rf test/swe/results/*/ test/swe/repos/",
55
+ "test:tbench:setup": "bash -c 'set -a; source .env.tbench; set +a; bash test/tbench/setup.sh'",
56
+ "test:tbench": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.tbench test/tbench/runner.js \"$@\" 2>&1 | tee /tmp/rummy_test_diag/tbench_$(date +%Y%m%dT%H%M%S).log' --",
57
+ "test:tbench:clean": "rm -rf test/tbench/results/*/",
54
58
  "test:clear": "rm -rf /tmp/rummy_test_diag /tmp/rummy_test_*.db /tmp/rummy_test_*.db-shm /tmp/rummy_test_*.db-wal /tmp/rummy-stories-*",
55
59
  "test:demo": "node --env-file-if-exists=.env.example --env-file-if-exists=.env bin/demo.js",
56
60
  "test:spec": "node test/spec-coverage.js"
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // Probe llama-server cache behavior. Send variations of the same request
3
+ // and inspect cached_tokens in the response usage block to determine
4
+ // whether caching is token-prefix or message-hash level.
5
+
6
+ const URL = "http://127.0.0.1:11435/v1/chat/completions";
7
+ const MODEL = "gemma-4-26B-A4B-it-UD-Q3_K_XL.gguf";
8
+
9
+ async function probe(label, system, user) {
10
+ const body = {
11
+ model: MODEL,
12
+ messages: [
13
+ { role: "system", content: system },
14
+ { role: "user", content: user },
15
+ ],
16
+ think: true,
17
+ temperature: 0.5,
18
+ };
19
+ const res = await fetch(URL, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify(body),
23
+ });
24
+ const data = await res.json();
25
+ const u = data.usage || {};
26
+ const cached =
27
+ u.prompt_tokens_details?.cached_tokens ??
28
+ u.cached_tokens ??
29
+ 0;
30
+ console.log(
31
+ `[${label}] prompt_tokens=${u.prompt_tokens ?? "?"} cached_tokens=${cached} system_chars=${system.length} user_chars=${user.length}`,
32
+ );
33
+ }
34
+
35
+ const STATIC_SYSTEM_BASE = `You are a helpful assistant.
36
+
37
+ Tools available:
38
+ - foo: does foo
39
+ - bar: does bar
40
+ - baz: does baz
41
+
42
+ Always be concise.`;
43
+
44
+ const ADDITION_A = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n</context>";
45
+ const ADDITION_B = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n<known path=\"k2\">second known fact</known>\n</context>";
46
+ const ADDITION_C = "\n\n<context>\n<known path=\"k2\">second known fact</known>\n<known path=\"k1\">first known fact</known>\n</context>";
47
+
48
+ const USER_A = "Hello.";
49
+
50
+ console.log("=== Run 1: baseline (cold, then immediate repeat) ===");
51
+ await probe("1a baseline cold", STATIC_SYSTEM_BASE, USER_A);
52
+ await probe("1b same-as-1a ", STATIC_SYSTEM_BASE, USER_A);
53
+
54
+ console.log("\n=== Run 2: same base, then base + appended context (prefix unchanged) ===");
55
+ await probe("2a base only ", STATIC_SYSTEM_BASE, USER_A);
56
+ await probe("2b base + 1 entry", STATIC_SYSTEM_BASE + ADDITION_A, USER_A);
57
+ await probe("2c base + 2 entries", STATIC_SYSTEM_BASE + ADDITION_B, USER_A);
58
+
59
+ console.log("\n=== Run 3: prefix change (entries reordered, same body) ===");
60
+ await probe("3a base + 2 entries (k1,k2)", STATIC_SYSTEM_BASE + ADDITION_B, USER_A);
61
+ await probe("3b base + 2 entries (k2,k1) reordered", STATIC_SYSTEM_BASE + ADDITION_C, USER_A);
62
+
63
+ console.log("\n=== Run 4: small mid-prefix change ===");
64
+ const MIDDIFF = STATIC_SYSTEM_BASE.replace("baz", "qux");
65
+ await probe("4a stable base ", STATIC_SYSTEM_BASE, USER_A);
66
+ await probe("4b changed baz→qux", MIDDIFF, USER_A);
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // Same probe as cache_probe.js but against OpenRouter's grok endpoint.
3
+ // If cached_tokens behaves sanely (incremental matches preserve prefix),
4
+ // then llama-server's behavior was the local anomaly.
5
+
6
+ const URL = `${process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"}/chat/completions`;
7
+ const MODEL = "x-ai/grok-4.1-fast";
8
+
9
+ if (!process.env.OPENROUTER_API_KEY) {
10
+ console.error("OPENROUTER_API_KEY required");
11
+ process.exit(1);
12
+ }
13
+
14
+ async function probe(label, system, user) {
15
+ const body = {
16
+ model: MODEL,
17
+ messages: [
18
+ { role: "system", content: system },
19
+ { role: "user", content: user },
20
+ ],
21
+ include_reasoning: true,
22
+ temperature: 0.5,
23
+ };
24
+ const res = await fetch(URL, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
29
+ },
30
+ body: JSON.stringify(body),
31
+ });
32
+ const data = await res.json();
33
+ const u = data.usage || {};
34
+ const cached =
35
+ u.prompt_tokens_details?.cached_tokens ??
36
+ u.cached_tokens ??
37
+ u.cache_read_input_tokens ??
38
+ 0;
39
+ console.log(
40
+ `[${label}] prompt_tokens=${u.prompt_tokens ?? "?"} cached_tokens=${cached} system_chars=${system.length}`,
41
+ );
42
+ }
43
+
44
+ const STATIC_SYSTEM_BASE = `You are a helpful assistant.
45
+
46
+ Tools available:
47
+ - foo: does foo
48
+ - bar: does bar
49
+ - baz: does baz
50
+
51
+ Always be concise.`;
52
+
53
+ const ADDITION_A = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n</context>";
54
+ const ADDITION_B = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n<known path=\"k2\">second known fact</known>\n</context>";
55
+ const ADDITION_C = "\n\n<context>\n<known path=\"k2\">second known fact</known>\n<known path=\"k1\">first known fact</known>\n</context>";
56
+
57
+ const USER = "Hello.";
58
+
59
+ console.log("=== Run 1: baseline (cold, then immediate repeat) ===");
60
+ await probe("1a baseline cold", STATIC_SYSTEM_BASE, USER);
61
+ await probe("1b same-as-1a ", STATIC_SYSTEM_BASE, USER);
62
+
63
+ console.log("\n=== Run 2: appended context (prefix unchanged) ===");
64
+ await probe("2a base + 1 ", STATIC_SYSTEM_BASE + ADDITION_A, USER);
65
+ await probe("2b base + 2 ", STATIC_SYSTEM_BASE + ADDITION_B, USER);
66
+
67
+ console.log("\n=== Run 3: reordered (entries shuffled) ===");
68
+ await probe("3a (k1,k2) ", STATIC_SYSTEM_BASE + ADDITION_B, USER);
69
+ await probe("3b (k2,k1) ", STATIC_SYSTEM_BASE + ADDITION_C, USER);
70
+
71
+ console.log("\n=== Run 4: mid-prefix character change ===");
72
+ const MIDDIFF = STATIC_SYSTEM_BASE.replace("baz", "qux");
73
+ await probe("4a stable base ", STATIC_SYSTEM_BASE, USER);
74
+ await probe("4b baz→qux ", MIDDIFF, USER);
package/service.js CHANGED
@@ -43,11 +43,7 @@ if (!rummyHome) {
43
43
  }
44
44
  for (const path of [homeExample, homeEnv]) {
45
45
  if (!existsSync(path)) continue;
46
- try {
47
- process.loadEnvFile(path);
48
- } catch (err) {
49
- console.warn(`[RUMMY] Failed to load ${path}: ${err.message}`);
50
- }
46
+ process.loadEnvFile(path);
51
47
  }
52
48
  }
53
49
  }
@@ -136,11 +132,21 @@ async function main() {
136
132
  }
137
133
  }
138
134
 
139
- // 6b. Database Hygiene
135
+ // 6b. Database Hygiene — opt-in via RUMMY_RETENTION_DAYS.
140
136
  const { statSync } = await import("node:fs");
141
- try {
137
+ const retentionRaw = process.env.RUMMY_RETENTION_DAYS;
138
+ if (retentionRaw == null || retentionRaw === "") {
139
+ const dbSizeMB = (statSync(dbPath).size / 1024 / 1024).toFixed(2);
140
+ console.log(`[RUMMY] DB size: ${dbSizeMB}MB`);
141
+ } else {
142
+ const retentionDays = Number.parseInt(retentionRaw, 10);
143
+ if (!Number.isInteger(retentionDays) || retentionDays < 0) {
144
+ throw new Error(
145
+ `Invalid RUMMY_RETENTION_DAYS=${JSON.stringify(retentionRaw)} ` +
146
+ "(expected non-negative integer)",
147
+ );
148
+ }
142
149
  const dbSizeBefore = statSync(dbPath).size;
143
- const retentionDays = Number.parseInt(process.env.RUMMY_RETENTION_DAYS, 10);
144
150
  await db.purge_old_runs.run({ retention_days: retentionDays });
145
151
  const dbSizeAfter = statSync(dbPath).size;
146
152
  const dbSizeMB = (dbSizeAfter / 1024 / 1024).toFixed(2);
@@ -153,8 +159,6 @@ async function main() {
153
159
  if (dbSizeAfter > 100 * 1024 * 1024) {
154
160
  console.warn(`[RUMMY] WARNING: Database exceeds 100MB. Consider manual cleanup.`);
155
161
  }
156
- } catch (err) {
157
- console.warn(`[RUMMY] Hygiene skipped: ${err.message}`);
158
162
  }
159
163
 
160
164
  // 6b. Abort stuck runs (can't be running if the server just started)
@@ -164,8 +168,15 @@ async function main() {
164
168
  console.log(`[RUMMY] Recovered ${aborted.changes} stuck run(s)`);
165
169
  }
166
170
 
171
+ // 6c. Boot complete — DB open, plugins inited, models loaded,
172
+ // hygiene done. Plugins that need a one-shot post-boot action
173
+ // (e.g. the cli plugin firing a programmatic run) subscribe to
174
+ // this event. Fires BEFORE SocketServer so RPC clients can't
175
+ // race a one-shot run still being set up.
176
+ await hooks.boot.completed.emit({ db, hooks });
177
+
167
178
  // 7. Start RPC Server
168
- const port = Number.parseInt(process.env.PORT);
179
+ const port = Number.parseInt(process.env.RUMMY_PORT);
169
180
  const server = new SocketServer(db, { port, hooks });
170
181
 
171
182
  server.on("error", (err) => {
@@ -1,4 +1,3 @@
1
- import { computeBudget } from "./budget.js";
2
1
  import msg from "./messages.js";
3
2
 
4
3
  const HTTP_TO_RUN_STATE = {
@@ -31,20 +30,13 @@ export default class AgentLoop {
31
30
  if (active) active.controller.abort();
32
31
  }
33
32
 
34
- /**
35
- * Abort every in-flight run and wait for each drain to settle.
36
- * Called from server close / client teardown so the process can
37
- * exit cleanly instead of leaving detached kickoff Promises
38
- * pinning the event loop.
39
- */
33
+ // Abort all in-flight runs and drain; rejections were already surfaced to original awaiters.
40
34
  async abortAll() {
41
35
  const promises = [];
42
36
  for (const { controller, promise } of this.#activeRuns.values()) {
43
37
  controller.abort();
44
38
  promises.push(promise);
45
39
  }
46
- // allSettled: drain waits for every run to finish; rejections are
47
- // already surfaced to whoever awaited the original run() call.
48
40
  await Promise.allSettled(promises);
49
41
  }
50
42
 
@@ -56,6 +48,24 @@ export default class AgentLoop {
56
48
  return `Turn ${turn}/${maxTurns}`;
57
49
  }
58
50
 
51
+ async #emitCompleted(hook, projectId, runId, out) {
52
+ const s = await this.#db.get_run_summary.get({ id: runId });
53
+ await hook.completed.emit({
54
+ projectId,
55
+ ...out,
56
+ model: s.model,
57
+ turns: s.turns,
58
+ cost: s.cost,
59
+ tokens: {
60
+ prompt: s.prompt_tokens,
61
+ cached: s.cached_tokens,
62
+ completion: s.completion_tokens,
63
+ reasoning: s.reasoning_tokens,
64
+ total: s.total_tokens,
65
+ },
66
+ });
67
+ }
68
+
59
69
  async #setRunStatus(runId, alias, httpStatus) {
60
70
  await this.#db.update_run_status.run({ id: runId, status: httpStatus });
61
71
  const state = HTTP_TO_RUN_STATE[httpStatus];
@@ -68,76 +78,6 @@ export default class AgentLoop {
68
78
  });
69
79
  }
70
80
 
71
- async #emitRunState({
72
- projectId,
73
- runId,
74
- alias,
75
- turn,
76
- status,
77
- contextSize,
78
- result = null,
79
- }) {
80
- if (!contextSize) throw new Error("#emitRunState: contextSize is required");
81
- const runUsage = await this.#db.get_run_usage.get({ run_id: runId });
82
- const history = await this.#entries.getLog(runId);
83
- const unknowns = await this.#entries.getUnknowns(runId);
84
- const latestSummary = this.#hooks.instructions.findLatestSummary(history);
85
-
86
- // Always emit complete telemetry. When we don't have a fresh turn
87
- // result (abort/max-turns/crash), read the last turn's context
88
- // tokens from the DB instead. Both code paths compute a real
89
- // budget from real data — never undefined, never invented.
90
- const rows = await this.#db.get_turn_context.all({
91
- run_id: runId,
92
- turn,
93
- });
94
- let totalTokens;
95
- if (result) {
96
- totalTokens = result.assembledTokens;
97
- } else {
98
- // No fresh turn result — this happens on abort/max-turns/crash
99
- // emits that fire before any turn executed, or after a turn
100
- // that never produced tokens. Read the last turn's assembled
101
- // context_tokens from the DB; absent means no turn ran yet
102
- // (zero is the truth, not a fallback).
103
- const lastCtx = await this.#db.get_last_context_tokens.get({
104
- run_id: runId,
105
- });
106
- totalTokens = lastCtx ? lastCtx.context_tokens : 0;
107
- }
108
- const budget = computeBudget({ rows, contextSize, totalTokens });
109
-
110
- await this.#hooks.run.state.emit({
111
- projectId,
112
- run: alias,
113
- turn,
114
- status,
115
- summary: latestSummary?.body,
116
- history,
117
- unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
118
- telemetry: {
119
- modelAlias: result?.modelAlias,
120
- model: result?.model,
121
- temperature: result?.temperature,
122
- context_size: contextSize,
123
- context_tokens: totalTokens,
124
- ceiling: budget.ceiling,
125
- token_usage: budget.tokenUsage,
126
- tokens_free: budget.tokensFree,
127
- prompt_tokens: runUsage.prompt_tokens,
128
- cached_tokens: runUsage.cached_tokens,
129
- completion_tokens: runUsage.completion_tokens,
130
- reasoning_tokens: runUsage.reasoning_tokens,
131
- total_tokens: runUsage.total_tokens,
132
- cost: runUsage.cost,
133
- context_distribution: await this.#db.get_turn_distribution.all({
134
- run_id: runId,
135
- turn,
136
- }),
137
- },
138
- });
139
- }
140
-
141
81
  async #writeRunEntry(
142
82
  runId,
143
83
  alias,
@@ -215,7 +155,6 @@ export default class AgentLoop {
215
155
  const existing = this.#activeRuns.get(existingRun.id);
216
156
  if (existing) existing.controller.abort();
217
157
 
218
- // Clean up stale proposals from interrupted runs
219
158
  const unresolved = await this.#entries.getUnresolved(existingRun.id);
220
159
  for (const u of unresolved) {
221
160
  await this.#entries.set({
@@ -228,7 +167,6 @@ export default class AgentLoop {
228
167
  }
229
168
  return { runId: existingRun.id, alias: existingRun.alias };
230
169
  }
231
- // Client-specified alias for a brand-new run — accept it verbatim.
232
170
  }
233
171
 
234
172
  const alias = run ? run : await this.#generateAlias(requestedModel);
@@ -314,8 +252,7 @@ export default class AgentLoop {
314
252
  return { run: currentAlias, status: 100 };
315
253
  }
316
254
 
317
- // Allocate the controller + Promise pair here so `abortAll` can
318
- // reach both — abort the controller, await the Promise's drain.
255
+ // Pair controller + Promise so abortAll can both signal and await drain.
319
256
  const controller = new AbortController();
320
257
  const promise = this.#drainQueue(
321
258
  currentRunId,
@@ -465,7 +402,7 @@ export default class AgentLoop {
465
402
  });
466
403
 
467
404
  let loopIteration = 0;
468
- const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS);
405
+ const MAX_LOOP_TURNS = Number(process.env.RUMMY_MAX_LOOP_TURNS);
469
406
 
470
407
  await this.#hooks.loop.started.emit({
471
408
  runId: currentRunId,
@@ -475,31 +412,23 @@ export default class AgentLoop {
475
412
  });
476
413
 
477
414
  try {
478
- while (loopIteration < MAX_LOOP_ITERATIONS) {
415
+ while (loopIteration < MAX_LOOP_TURNS) {
479
416
  if (signal.aborted) {
480
417
  console.error(
481
418
  `[LOOP] ${currentAlias} iter=${loopIteration} ABORT via signal`,
482
419
  );
483
420
  await this.#setRunStatus(currentRunId, currentAlias, 499);
484
- await this.#emitRunState({
485
- projectId,
486
- runId: currentRunId,
487
- alias: currentAlias,
488
- turn: loopIteration,
489
- status: 499,
490
- contextSize,
491
- });
492
421
  const out = {
493
422
  run: currentAlias,
494
423
  status: 499,
495
424
  turn: loopIteration,
496
425
  };
497
- await hook.completed.emit({ projectId, ...out });
426
+ await this.#emitCompleted(hook, projectId, currentRunId, out);
498
427
  return out;
499
428
  }
500
429
  loopIteration++;
501
430
  console.error(
502
- `[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${MAX_LOOP_ITERATIONS})`,
431
+ `[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${MAX_LOOP_TURNS})`,
503
432
  );
504
433
 
505
434
  let turnPrompt;
@@ -508,7 +437,7 @@ export default class AgentLoop {
508
437
  } else {
509
438
  turnPrompt = this.#buildContinuationPrompt(
510
439
  loopIteration,
511
- MAX_LOOP_ITERATIONS,
440
+ MAX_LOOP_TURNS,
512
441
  );
513
442
  }
514
443
 
@@ -553,15 +482,6 @@ export default class AgentLoop {
553
482
  `[LOOP] ${currentAlias} iter=${loopIteration} verdict: continue=${verdict.continue} status=${vStatus} reason=${vReason}`,
554
483
  );
555
484
 
556
- await this.#emitRunState({
557
- projectId,
558
- runId: currentRunId,
559
- alias: currentAlias,
560
- turn: result.turn,
561
- status: verdict.continue ? 102 : verdict.status,
562
- contextSize,
563
- result,
564
- });
565
485
  await this.#hooks.run.step.completed.emit({
566
486
  projectId,
567
487
  run: currentAlias,
@@ -588,41 +508,24 @@ export default class AgentLoop {
588
508
  turn: result.turn,
589
509
  reason: verdict.reason,
590
510
  };
591
- await hook.completed.emit({ projectId, ...out });
511
+ await this.#emitCompleted(hook, projectId, currentRunId, out);
592
512
  return out;
593
513
  }
594
514
 
595
- // MAX_TURNS exhaustion without a terminal update is abandonment.
596
515
  console.error(
597
- `[LOOP] ${currentAlias} hit MAX_LOOP_ITERATIONS=${MAX_LOOP_ITERATIONS} — abandoning at 499`,
516
+ `[LOOP] ${currentAlias} hit MAX_LOOP_TURNS=${MAX_LOOP_TURNS} — abandoning at 499`,
598
517
  );
599
518
  await this.#setRunStatus(currentRunId, currentAlias, 499);
600
- await this.#emitRunState({
601
- projectId,
602
- runId: currentRunId,
603
- alias: currentAlias,
604
- turn: loopIteration,
605
- status: 499,
606
- contextSize,
607
- });
608
519
  const out = {
609
520
  run: currentAlias,
610
521
  status: 499,
611
522
  turn: loopIteration,
612
523
  };
613
- await hook.completed.emit({ projectId, ...out });
524
+ await this.#emitCompleted(hook, projectId, currentRunId, out);
614
525
  return out;
615
526
  } catch (err) {
616
527
  const status = signal.aborted ? 499 : 500;
617
528
  await this.#setRunStatus(currentRunId, currentAlias, status);
618
- await this.#emitRunState({
619
- projectId,
620
- runId: currentRunId,
621
- alias: currentAlias,
622
- turn: loopIteration,
623
- status,
624
- contextSize,
625
- });
626
529
  if (status === 500) {
627
530
  await this.#hooks.error.log.emit({
628
531
  store: this.#entries,
@@ -634,7 +537,7 @@ export default class AgentLoop {
634
537
  }
635
538
  const out = { run: currentAlias, status, turn: loopIteration };
636
539
  if (status === 500) out.error = err.message;
637
- await hook.completed.emit({ projectId, ...out });
540
+ await this.#emitCompleted(hook, projectId, currentRunId, out);
638
541
  return out;
639
542
  } finally {
640
543
  await this.#hooks.loop.completed.emit({
@@ -674,11 +577,7 @@ export default class AgentLoop {
674
577
  db: this.#db,
675
578
  entries: this.#entries,
676
579
  });
677
- // Report the CURRENT run status (typically 102 mid-run) so the
678
- // client's dispatch handler doesn't mistake a successful
679
- // resolve's HTTP-style 200 ack for a terminal run status and
680
- // prematurely close the document. Real terminal state comes
681
- // from the run/state notification at end-of-turn.
580
+ // Return current run status (not 200) so client doesn't close on resolve ack.
682
581
  return { run: runAlias, status: runRow.status };
683
582
  }
684
583
 
@@ -698,8 +597,7 @@ export default class AgentLoop {
698
597
  entries: this.#entries,
699
598
  };
700
599
 
701
- // Plugins veto acceptance (e.g. readonly) via proposal.accepting.
702
- // First veto wins: state=failed with plugin-supplied outcome + body.
600
+ // First plugin veto wins via proposal.accepting (e.g. readonly).
703
601
  if (action === "accept") {
704
602
  const veto = await this.#hooks.proposal.accepting.filter(null, ctx);
705
603
  if (veto?.allow === false) {
@@ -714,9 +612,7 @@ export default class AgentLoop {
714
612
  }
715
613
  }
716
614
 
717
- // Compose the resolved body. Default is output || "". Plugins may
718
- // override via proposal.content (e.g. set prefers the existing
719
- // proposed body from the log entry).
615
+ // proposal.content override lets plugins prefer the proposed body (e.g. set).
720
616
  const defaultBody = output ? output : "";
721
617
  const resolvedBody = await this.#hooks.proposal.content.filter(
722
618
  defaultBody,
@@ -741,9 +637,7 @@ export default class AgentLoop {
741
637
  : this.#hooks.proposal.rejected;
742
638
  await event.emit({ ...ctx, resolvedBody });
743
639
 
744
- // Same rationale as the reject path: return current run status
745
- // (102 mid-run) rather than a hardcoded 200 so the nvim client
746
- // doesn't treat the RPC ack as a terminal signal.
640
+ // Return current run status (not 200) so client doesn't close on resolve ack.
747
641
  return { run: runAlias, status: runRow.status };
748
642
  }
749
643
 
@@ -1,8 +1,4 @@
1
- /**
2
- * Thin orchestrator. Computes loopStartTurn from the rows,
3
- * then invokes assembly.system and assembly.user filter chains.
4
- * All rendering logic lives in plugins.
5
- */
1
+ // Orchestrates assembly.system / assembly.user filter chains; plugins do all rendering.
6
2
  export default class ContextAssembler {
7
3
  static async assembleFromTurnContext(
8
4
  rows,
@@ -10,15 +6,13 @@ export default class ContextAssembler {
10
6
  type = "ask",
11
7
  systemPrompt = "",
12
8
  contextSize = 0,
13
- demoted = [],
14
9
  toolSet = null,
15
10
  lastContextTokens = 0,
16
11
  turn = 1,
17
12
  } = {},
18
13
  hooks,
19
14
  ) {
20
- // Find loop boundary from active prompt. Absent on turn 1 before
21
- // the prompt plugin's turn.started handler has run.
15
+ // Loop boundary from active prompt; absent on turn 1 before prompt plugin's turn.started.
22
16
  const promptEntry = rows.findLast(
23
17
  (r) => r.category === "prompt" && r.scheme === "prompt",
24
18
  );
@@ -31,7 +25,6 @@ export default class ContextAssembler {
31
25
  type,
32
26
  contextSize,
33
27
  lastContextTokens,
34
- demoted,
35
28
  toolSet,
36
29
  turn,
37
30
  };