@possumtech/rummy 0.2.8 → 0.3.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 (108) hide show
  1. package/.env.example +11 -1
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +6 -4
  6. package/package.json +13 -5
  7. package/src/agent/AgentLoop.js +166 -15
  8. package/src/agent/ContextAssembler.js +18 -4
  9. package/src/agent/KnownStore.js +127 -13
  10. package/src/agent/ProjectAgent.js +4 -1
  11. package/src/agent/ResponseHealer.js +21 -1
  12. package/src/agent/TurnExecutor.js +365 -175
  13. package/src/agent/XmlParser.js +72 -39
  14. package/src/agent/known_store.sql +20 -4
  15. package/src/agent/schemes.sql +3 -0
  16. package/src/agent/tokens.js +6 -21
  17. package/src/agent/turns.sql +10 -1
  18. package/src/hooks/Hooks.js +18 -0
  19. package/src/hooks/PluginContext.js +14 -1
  20. package/src/hooks/RummyContext.js +16 -4
  21. package/src/hooks/ToolRegistry.js +83 -19
  22. package/src/llm/LlmProvider.js +27 -8
  23. package/src/llm/OpenAiClient.js +20 -0
  24. package/src/llm/OpenRouterClient.js +24 -2
  25. package/src/llm/XaiClient.js +47 -2
  26. package/src/plugins/ask_user/README.md +4 -4
  27. package/src/plugins/ask_user/ask_user.js +5 -5
  28. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  29. package/src/plugins/budget/BudgetGuard.js +74 -0
  30. package/src/plugins/budget/README.md +43 -0
  31. package/src/plugins/budget/budget.js +79 -0
  32. package/src/plugins/cp/README.md +5 -4
  33. package/src/plugins/cp/cp.js +10 -6
  34. package/src/plugins/cp/cpDoc.js +29 -0
  35. package/src/plugins/current/README.md +4 -4
  36. package/src/plugins/current/current.js +9 -6
  37. package/src/plugins/engine/engine.sql +1 -8
  38. package/src/plugins/engine/turn_context.sql +4 -9
  39. package/src/plugins/env/README.md +3 -4
  40. package/src/plugins/env/env.js +5 -5
  41. package/src/plugins/env/envDoc.js +29 -0
  42. package/src/plugins/file/README.md +9 -12
  43. package/src/plugins/file/file.js +34 -35
  44. package/src/plugins/get/README.md +2 -2
  45. package/src/plugins/get/get.js +6 -5
  46. package/src/plugins/get/getDoc.js +41 -0
  47. package/src/plugins/hedberg/hedberg.js +2 -1
  48. package/src/plugins/hedberg/normalize.js +28 -0
  49. package/src/plugins/hedberg/patterns.js +25 -27
  50. package/src/plugins/hedberg/sed.js +17 -10
  51. package/src/plugins/index.js +66 -14
  52. package/src/plugins/instructions/README.md +6 -2
  53. package/src/plugins/instructions/instructions.js +20 -4
  54. package/src/plugins/instructions/preamble.md +9 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +29 -17
  57. package/src/plugins/known/knownDoc.js +33 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +10 -6
  60. package/src/plugins/mv/mvDoc.js +31 -0
  61. package/src/plugins/persona/persona.js +78 -0
  62. package/src/plugins/previous/README.md +2 -2
  63. package/src/plugins/previous/previous.js +9 -6
  64. package/src/plugins/progress/progress.js +41 -15
  65. package/src/plugins/prompt/README.md +5 -5
  66. package/src/plugins/prompt/prompt.js +18 -13
  67. package/src/plugins/rm/README.md +4 -4
  68. package/src/plugins/rm/rm.js +5 -5
  69. package/src/plugins/rm/rmDoc.js +30 -0
  70. package/src/plugins/rpc/README.md +15 -28
  71. package/src/plugins/rpc/rpc.js +42 -77
  72. package/src/plugins/set/README.md +13 -12
  73. package/src/plugins/set/set.js +60 -5
  74. package/src/plugins/set/setDoc.js +45 -0
  75. package/src/plugins/sh/README.md +4 -4
  76. package/src/plugins/sh/sh.js +5 -5
  77. package/src/plugins/sh/shDoc.js +29 -0
  78. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  79. package/src/plugins/summarize/README.md +6 -5
  80. package/src/plugins/summarize/summarize.js +7 -6
  81. package/src/plugins/summarize/summarizeDoc.js +33 -0
  82. package/src/plugins/telemetry/telemetry.js +3 -1
  83. package/src/plugins/think/README.md +20 -0
  84. package/src/plugins/think/think.js +5 -0
  85. package/src/plugins/unknown/README.md +5 -5
  86. package/src/plugins/unknown/unknown.js +9 -7
  87. package/src/plugins/unknown/unknownDoc.js +31 -0
  88. package/src/plugins/update/README.md +3 -8
  89. package/src/plugins/update/update.js +7 -6
  90. package/src/plugins/update/updateDoc.js +33 -0
  91. package/src/server/RpcRegistry.js +52 -4
  92. package/src/sql/v_model_context.sql +16 -21
  93. package/src/plugins/ask_user/docs.md +0 -2
  94. package/src/plugins/cp/docs.md +0 -2
  95. package/src/plugins/env/docs.md +0 -4
  96. package/src/plugins/get/docs.md +0 -10
  97. package/src/plugins/known/docs.md +0 -3
  98. package/src/plugins/mv/docs.md +0 -2
  99. package/src/plugins/rm/docs.md +0 -6
  100. package/src/plugins/set/docs.md +0 -6
  101. package/src/plugins/sh/docs.md +0 -2
  102. package/src/plugins/skills/README.md +0 -25
  103. package/src/plugins/store/README.md +0 -20
  104. package/src/plugins/store/docs.md +0 -6
  105. package/src/plugins/store/store.js +0 -63
  106. package/src/plugins/summarize/docs.md +0 -4
  107. package/src/plugins/unknown/docs.md +0 -5
  108. package/src/plugins/update/docs.md +0 -4
@@ -32,7 +32,7 @@ export default class AgentLoop {
32
32
  }
33
33
 
34
34
  async #ensureRun(projectId, model, run, options) {
35
- const _noContext = options?.noContext === true;
35
+ const _noRepo = options?.noRepo === true;
36
36
  const isFork = options?.fork === true;
37
37
  const requestedModel = model;
38
38
 
@@ -54,6 +54,11 @@ export default class AgentLoop {
54
54
  new_run_id: runRow.id,
55
55
  parent_run_id: existingRun.id,
56
56
  });
57
+ await this.#hooks.run.created.emit({
58
+ runId: runRow.id,
59
+ alias,
60
+ forkedFrom: existingRun.id,
61
+ });
57
62
  return { runId: runRow.id, alias };
58
63
  }
59
64
 
@@ -87,6 +92,7 @@ export default class AgentLoop {
87
92
  persona: options?.persona ?? null,
88
93
  context_limit: options?.contextLimit ?? null,
89
94
  });
95
+ await this.#hooks.run.created.emit({ runId: runRow.id, alias });
90
96
  return { runId: runRow.id, alias };
91
97
  }
92
98
 
@@ -112,7 +118,9 @@ export default class AgentLoop {
112
118
  if (!project)
113
119
  throw new Error(msg("error.project_not_found", { projectId }));
114
120
 
115
- const noContext = options?.noContext === true;
121
+ const noRepo = options?.noRepo === true;
122
+ const noInteraction = options?.noInteraction === true;
123
+ const noWeb = options?.noWeb === true;
116
124
  const requestedModel = model;
117
125
 
118
126
  const runInfo = await this.#ensureRun(projectId, model, run, options);
@@ -134,7 +142,12 @@ export default class AgentLoop {
134
142
  mode,
135
143
  model: requestedModel,
136
144
  prompt: prompt || "",
137
- config: JSON.stringify({ noContext, temperature: options?.temperature }),
145
+ config: JSON.stringify({
146
+ noRepo,
147
+ noInteraction,
148
+ noWeb,
149
+ temperature: options?.temperature,
150
+ }),
138
151
  });
139
152
 
140
153
  if (this.#activeRuns.has(currentRunId)) {
@@ -151,6 +164,8 @@ export default class AgentLoop {
151
164
  }
152
165
 
153
166
  async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
167
+ let panicAttempted = false;
168
+
154
169
  while (true) {
155
170
  const loop = await this.#db.claim_next_loop.get({
156
171
  run_id: currentRunId,
@@ -158,6 +173,13 @@ export default class AgentLoop {
158
173
  if (!loop) break;
159
174
 
160
175
  const loopConfig = loop.config ? JSON.parse(loop.config) : {};
176
+ const hook =
177
+ loop.mode === "panic"
178
+ ? this.#hooks.panic
179
+ : loop.mode === "ask"
180
+ ? this.#hooks.ask
181
+ : this.#hooks.act;
182
+
161
183
  const result = await this.#executeLoop({
162
184
  mode: loop.mode,
163
185
  project,
@@ -167,11 +189,64 @@ export default class AgentLoop {
167
189
  currentLoopId: loop.id,
168
190
  requestedModel: loop.model,
169
191
  prompt: loop.prompt,
170
- noContext: loopConfig.noContext || false,
192
+ noRepo: loopConfig.noRepo || false,
193
+ noInteraction: loopConfig.noInteraction || false,
194
+ noWeb: loopConfig.noWeb || false,
171
195
  options: { ...options, temperature: loopConfig.temperature },
172
- hook: loop.mode === "ask" ? this.#hooks.ask : this.#hooks.act,
196
+ hook,
173
197
  });
174
198
 
199
+ if (result.status === 413) {
200
+ await this.#db.complete_loop.run({
201
+ id: loop.id,
202
+ status: 413,
203
+ result: JSON.stringify(result),
204
+ });
205
+
206
+ // One panic attempt per drain cycle
207
+ if (loop.mode === "panic" || panicAttempted) {
208
+ return {
209
+ run: currentAlias,
210
+ status: 413,
211
+ error:
212
+ loop.mode === "panic"
213
+ ? `Panic mode failed to free enough space (${result.overflow} tokens over).`
214
+ : `Context full (${result.overflow} tokens over).`,
215
+ };
216
+ }
217
+
218
+ panicAttempted = true;
219
+
220
+ const panicPrompt = this.#hooks.budget.panicPrompt({
221
+ assembledTokens: result.assembledTokens,
222
+ contextSize: result.contextSize,
223
+ });
224
+
225
+ // Enqueue panic loop
226
+ const panicSeq = await this.#db.next_loop.get({ run_id: currentRunId });
227
+ await this.#db.enqueue_loop.get({
228
+ run_id: currentRunId,
229
+ sequence: panicSeq.sequence,
230
+ mode: "panic",
231
+ model: loop.model,
232
+ prompt: panicPrompt,
233
+ config: JSON.stringify({ noRepo: true }),
234
+ });
235
+
236
+ // Re-enqueue the original loop to retry after panic
237
+ const retrySeq = await this.#db.next_loop.get({ run_id: currentRunId });
238
+ await this.#db.enqueue_loop.get({
239
+ run_id: currentRunId,
240
+ sequence: retrySeq.sequence,
241
+ mode: loop.mode,
242
+ model: loop.model,
243
+ prompt: loop.prompt,
244
+ config: loop.config,
245
+ });
246
+
247
+ continue;
248
+ }
249
+
175
250
  await this.#db.complete_loop.run({
176
251
  id: loop.id,
177
252
  status: result.status === 202 ? 202 : result.status,
@@ -194,7 +269,9 @@ export default class AgentLoop {
194
269
  currentLoopId,
195
270
  requestedModel,
196
271
  prompt,
197
- noContext,
272
+ noRepo,
273
+ noInteraction,
274
+ noWeb,
198
275
  options,
199
276
  hook,
200
277
  }) {
@@ -212,6 +289,11 @@ export default class AgentLoop {
212
289
  ? Math.min(runRow.context_limit, modelContextSize)
213
290
  : modelContextSize;
214
291
 
292
+ const toolSet = this.#hooks.tools.resolveForLoop(mode, {
293
+ noInteraction,
294
+ noWeb,
295
+ });
296
+
215
297
  let loopIteration = 0;
216
298
  const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
217
299
  const healer = new ResponseHealer();
@@ -219,6 +301,17 @@ export default class AgentLoop {
219
301
  const controller = new AbortController();
220
302
  this.#activeRuns.set(currentRunId, controller);
221
303
 
304
+ let _lastAssembledTokens = 0;
305
+ let _panicStrikes = 0;
306
+ let _lastPanicTokens = null;
307
+
308
+ await this.#hooks.loop.started.emit({
309
+ runId: currentRunId,
310
+ loopId: currentLoopId,
311
+ mode,
312
+ prompt,
313
+ });
314
+
222
315
  try {
223
316
  while (loopIteration < MAX_LOOP_ITERATIONS) {
224
317
  if (controller.signal.aborted) {
@@ -239,6 +332,8 @@ export default class AgentLoop {
239
332
  let turnPrompt;
240
333
  if (loopIteration === 1) {
241
334
  turnPrompt = prompt;
335
+ } else if (mode === "panic") {
336
+ turnPrompt = "Continue freeing space. Check <knowns> token counts.";
242
337
  } else {
243
338
  turnPrompt = this.#buildContinuationPrompt(
244
339
  loopIteration,
@@ -255,12 +350,67 @@ export default class AgentLoop {
255
350
  currentLoopId,
256
351
  requestedModel,
257
352
  loopPrompt: turnPrompt,
258
- noContext,
353
+ noRepo,
354
+ toolSet,
259
355
  contextSize,
260
356
  options: { ...options, isContinuation: loopIteration > 1 },
261
357
  signal: controller.signal,
262
358
  });
263
359
 
360
+ // Budget overflow — return 413 to drainQueue for panic mode
361
+ if (result.status === 413) {
362
+ return {
363
+ run: currentAlias,
364
+ status: 413,
365
+ overflow: result.overflow,
366
+ assembledTokens: result.assembledTokens,
367
+ contextSize: result.contextSize,
368
+ turn: result.turn,
369
+ };
370
+ }
371
+
372
+ _lastAssembledTokens = result.assembledTokens;
373
+
374
+ // Panic mode: target check + strike counting
375
+ if (mode === "panic") {
376
+ const panicTarget = Math.floor(contextSize * 0.75);
377
+ if (result.assembledTokens <= panicTarget) {
378
+ await this.#db.update_run_status.run({
379
+ id: currentRunId,
380
+ status: 200,
381
+ });
382
+ const out = {
383
+ run: currentAlias,
384
+ status: 200,
385
+ turn: result.turn,
386
+ };
387
+ await hook.completed.emit({ projectId, ...out });
388
+ return out;
389
+ }
390
+ if (_lastPanicTokens !== null) {
391
+ if (result.assembledTokens < _lastPanicTokens) {
392
+ _panicStrikes = 0;
393
+ } else {
394
+ _panicStrikes++;
395
+ if (_panicStrikes >= 3) {
396
+ await this.#db.update_run_status.run({
397
+ id: currentRunId,
398
+ status: 200,
399
+ });
400
+ return {
401
+ run: currentAlias,
402
+ status: 413,
403
+ overflow: result.assembledTokens - contextSize,
404
+ assembledTokens: result.assembledTokens,
405
+ contextSize,
406
+ turn: result.turn,
407
+ };
408
+ }
409
+ }
410
+ }
411
+ _lastPanicTokens = result.assembledTokens;
412
+ }
413
+
264
414
  const runUsage = await this.#db.get_run_usage.get({
265
415
  run_id: currentRunId,
266
416
  });
@@ -409,6 +559,14 @@ export default class AgentLoop {
409
559
  return out;
410
560
  } finally {
411
561
  this.#activeRuns.delete(currentRunId);
562
+ await this.#hooks.loop.completed
563
+ .emit({
564
+ runId: currentRunId,
565
+ loopId: currentLoopId,
566
+ mode,
567
+ turns: loopIteration,
568
+ })
569
+ .catch(() => {});
412
570
  }
413
571
  }
414
572
 
@@ -546,16 +704,9 @@ export default class AgentLoop {
546
704
  runRow.id,
547
705
  nextTurn,
548
706
  `prompt://${nextTurn}`,
549
- "",
550
- 200,
551
- { attributes: { mode: "ask" } },
552
- );
553
- await this.#knownStore.upsert(
554
- runRow.id,
555
- nextTurn,
556
- `ask://${nextTurn}`,
557
707
  message,
558
708
  200,
709
+ { attributes: { mode: "ask" } },
559
710
  );
560
711
 
561
712
  if (this.#activeRuns.has(runRow.id)) {
@@ -6,17 +6,31 @@
6
6
  export default class ContextAssembler {
7
7
  static async assembleFromTurnContext(
8
8
  rows,
9
- { type = "ask", systemPrompt = "", contextSize = 0, demoted = [] } = {},
9
+ {
10
+ type = "ask",
11
+ systemPrompt = "",
12
+ contextSize = 0,
13
+ demoted = [],
14
+ toolSet = null,
15
+ lastContextTokens = 0,
16
+ } = {},
10
17
  hooks,
11
18
  ) {
12
19
  // Find loop boundary from active prompt
13
20
  const promptEntry = rows.findLast(
14
- (r) =>
15
- r.category === "prompt" && (r.scheme === "ask" || r.scheme === "act"),
21
+ (r) => r.category === "prompt" && r.scheme === "prompt",
16
22
  );
17
23
  const loopStartTurn = promptEntry?.source_turn ?? 0;
18
24
 
19
- const ctx = { rows, loopStartTurn, type, contextSize, demoted };
25
+ const ctx = {
26
+ rows,
27
+ loopStartTurn,
28
+ type,
29
+ contextSize,
30
+ lastContextTokens,
31
+ demoted,
32
+ toolSet,
33
+ };
20
34
 
21
35
  const system = await hooks.assembly.system.filter(systemPrompt, ctx);
22
36
  const user = await hooks.assembly.user.filter("", ctx);
@@ -1,10 +1,42 @@
1
1
  import slugify from "../sql/functions/slugify.js";
2
+ import { countTokens } from "./tokens.js";
2
3
 
3
4
  export default class KnownStore {
4
5
  #db;
6
+ #onChanged;
7
+ #budgetGuard = null;
8
+ #schemes = new Map();
5
9
 
6
- constructor(db) {
10
+ constructor(db, { onChanged } = {}) {
7
11
  this.#db = db;
12
+ this.#onChanged = onChanged || null;
13
+ }
14
+
15
+ get budgetGuard() {
16
+ return this.#budgetGuard;
17
+ }
18
+
19
+ set budgetGuard(guard) {
20
+ this.#budgetGuard = guard;
21
+ }
22
+
23
+ async loadSchemes(db) {
24
+ const rows = await (db || this.#db).get_all_schemes.all();
25
+ this.#schemes.clear();
26
+ for (const row of rows) {
27
+ this.#schemes.set(row.name, row);
28
+ }
29
+ }
30
+
31
+ #isVisible(path, fidelity) {
32
+ if (fidelity === "archive") return false;
33
+ const scheme = KnownStore.scheme(path) ?? "file";
34
+ const meta = this.#schemes.get(scheme);
35
+ return meta ? meta.model_visible !== 0 : true;
36
+ }
37
+
38
+ #emitChanged(runId, path, changeType) {
39
+ if (this.#onChanged) this.#onChanged({ runId, path, changeType });
8
40
  }
9
41
 
10
42
  static scheme(path) {
@@ -30,18 +62,21 @@ export default class KnownStore {
30
62
  return row.turn;
31
63
  }
32
64
 
33
- async dedup(runId, scheme, target) {
34
- const candidate = `${scheme}://${target}`;
65
+ async dedup(runId, scheme, target, turn) {
66
+ const encodedTarget = encodeURIComponent(target);
67
+ const turnPrefix = turn ? `turn_${turn}/` : "";
68
+ const candidate = `${scheme}://${turnPrefix}${encodedTarget}`;
35
69
  const existing = await this.#db.get_entry_body.get({
36
70
  run_id: runId,
37
- path: KnownStore.normalizePath(candidate),
71
+ path: candidate,
38
72
  });
39
73
  if (!existing) return candidate;
40
74
  return `${candidate}_${Date.now()}`;
41
75
  }
42
76
 
43
- async slugPath(runId, scheme, content) {
44
- const base = slugify(content || "");
77
+ async slugPath(runId, scheme, content, summary) {
78
+ const source = summary ? summary.replace(/,\s*/g, "/") : content || "";
79
+ const base = slugify(source);
45
80
  const prefix = `${scheme}://`;
46
81
 
47
82
  if (!base) return `${prefix}${Date.now()}`;
@@ -70,11 +105,28 @@ export default class KnownStore {
70
105
  loopId = null,
71
106
  } = {},
72
107
  ) {
108
+ const normalized = KnownStore.normalizePath(path);
109
+ let delta = 0;
110
+
111
+ if (
112
+ this.#budgetGuard &&
113
+ status < 400 &&
114
+ this.#isVisible(normalized, fidelity)
115
+ ) {
116
+ const existing = await this.#db.get_entry_body.get({
117
+ run_id: runId,
118
+ path: normalized,
119
+ });
120
+ delta =
121
+ countTokens(body) - (existing?.body ? countTokens(existing.body) : 0);
122
+ this.#budgetGuard.check(delta, normalized);
123
+ }
124
+
73
125
  await this.#db.upsert_known_entry.run({
74
126
  run_id: runId,
75
127
  loop_id: loopId,
76
128
  turn,
77
- path: KnownStore.normalizePath(path),
129
+ path: normalized,
78
130
  body,
79
131
  status,
80
132
  fidelity,
@@ -82,14 +134,19 @@ export default class KnownStore {
82
134
  attributes: attributes ? JSON.stringify(attributes) : null,
83
135
  updated_at: updatedAt,
84
136
  });
137
+ this.#emitChanged(runId, normalized, "upsert");
138
+
139
+ if (delta > 0) this.#budgetGuard?.charge(delta);
85
140
  }
86
141
 
87
142
  async promote(runId, path, turn) {
143
+ const normalized = KnownStore.normalizePath(path);
88
144
  await this.#db.promote_path.run({
89
145
  run_id: runId,
90
- path: KnownStore.normalizePath(path),
146
+ path: normalized,
91
147
  turn,
92
148
  });
149
+ this.#emitChanged(runId, normalized, "promote");
93
150
  }
94
151
 
95
152
  async setFileFidelity(runId, pattern, fidelity) {
@@ -101,28 +158,35 @@ export default class KnownStore {
101
158
  if (result.changes === 0) {
102
159
  await this.upsert(runId, 0, pattern, "", 200, { fidelity });
103
160
  }
161
+ this.#emitChanged(runId, pattern, "fidelity");
104
162
  }
105
163
 
106
164
  async setFidelity(runId, path, fidelity) {
165
+ const normalized = KnownStore.normalizePath(path);
107
166
  await this.#db.set_fidelity.run({
108
167
  run_id: runId,
109
- path: KnownStore.normalizePath(path),
168
+ path: normalized,
110
169
  fidelity,
111
170
  });
171
+ this.#emitChanged(runId, normalized, "fidelity");
112
172
  }
113
173
 
114
174
  async demote(runId, path) {
175
+ const normalized = KnownStore.normalizePath(path);
115
176
  await this.#db.demote_path.run({
116
177
  run_id: runId,
117
- path: KnownStore.normalizePath(path),
178
+ path: normalized,
118
179
  });
180
+ this.#emitChanged(runId, normalized, "demote");
119
181
  }
120
182
 
121
183
  async remove(runId, path) {
184
+ const normalized = KnownStore.normalizePath(path);
122
185
  await this.#db.delete_known_entry.run({
123
186
  run_id: runId,
124
- path: KnownStore.normalizePath(path),
187
+ path: normalized,
125
188
  });
189
+ this.#emitChanged(runId, normalized, "remove");
126
190
  }
127
191
 
128
192
  async removeFilesByPattern(runId, pattern) {
@@ -130,6 +194,7 @@ export default class KnownStore {
130
194
  run_id: runId,
131
195
  pattern,
132
196
  });
197
+ this.#emitChanged(runId, pattern, "remove");
133
198
  }
134
199
 
135
200
  static #bodyPattern(body) {
@@ -137,12 +202,30 @@ export default class KnownStore {
137
202
  }
138
203
 
139
204
  async promoteByPattern(runId, path, body, turn) {
205
+ let cost = 0;
206
+ if (this.#budgetGuard) {
207
+ const entries = await this.#db.get_entries_by_pattern.all({
208
+ run_id: runId,
209
+ path,
210
+ body: KnownStore.#bodyPattern(body),
211
+ limit: null,
212
+ offset: null,
213
+ });
214
+ cost = entries
215
+ .filter((e) => e.fidelity === "archive" || e.fidelity === "index")
216
+ .reduce((sum, e) => sum + (e.tokens_full || 0), 0);
217
+ if (cost > 0) this.#budgetGuard.check(cost, path);
218
+ }
219
+
140
220
  await this.#db.promote_by_pattern.run({
141
221
  run_id: runId,
142
222
  path,
143
223
  body: KnownStore.#bodyPattern(body),
144
224
  turn,
145
225
  });
226
+ this.#emitChanged(runId, path, "promote");
227
+
228
+ if (cost > 0) this.#budgetGuard?.charge(cost);
146
229
  }
147
230
 
148
231
  async demoteByPattern(runId, path, body) {
@@ -151,6 +234,7 @@ export default class KnownStore {
151
234
  path,
152
235
  body: KnownStore.#bodyPattern(body),
153
236
  });
237
+ this.#emitChanged(runId, path, "demote");
154
238
  }
155
239
 
156
240
  async getEntriesByPattern(runId, path, body, { limit, offset } = {}) {
@@ -169,30 +253,58 @@ export default class KnownStore {
169
253
  path,
170
254
  body: KnownStore.#bodyPattern(body),
171
255
  });
256
+ this.#emitChanged(runId, path, "remove");
172
257
  }
173
258
 
174
259
  async updateBodyByPattern(runId, path, body, newBody) {
260
+ let delta = 0;
261
+ if (this.#budgetGuard) {
262
+ const entries = await this.#db.get_entries_by_pattern.all({
263
+ run_id: runId,
264
+ path,
265
+ body: KnownStore.#bodyPattern(body),
266
+ limit: null,
267
+ offset: null,
268
+ });
269
+ const visible = entries.filter((e) =>
270
+ this.#isVisible(e.path, e.fidelity),
271
+ );
272
+ const oldTotal = visible.reduce((sum, e) => sum + (e.tokens || 0), 0);
273
+ const newTokensPer = countTokens(newBody);
274
+ delta = newTokensPer * visible.length - oldTotal;
275
+ if (delta > 0) this.#budgetGuard.check(delta, path);
276
+ }
277
+
175
278
  await this.#db.update_body_by_pattern.run({
176
279
  run_id: runId,
177
280
  path,
178
281
  body: KnownStore.#bodyPattern(body),
179
282
  new_body: newBody,
180
283
  });
284
+ this.#emitChanged(runId, path, "body");
285
+
286
+ if (delta > 0) this.#budgetGuard?.charge(delta);
181
287
  }
182
288
 
183
289
  async resolve(runId, path, status, body) {
290
+ const normalized = KnownStore.normalizePath(path);
184
291
  await this.#db.resolve_known_entry.run({
185
292
  run_id: runId,
186
- path: KnownStore.normalizePath(path),
293
+ path: normalized,
187
294
  status,
188
295
  body,
189
296
  });
297
+ this.#emitChanged(runId, normalized, "resolve");
190
298
  }
191
299
 
192
300
  async getLog(runId) {
193
301
  return this.#db.get_results.all({ run_id: runId });
194
302
  }
195
303
 
304
+ async getEntries(runId) {
305
+ return this.#db.get_known_entries.all({ run_id: runId });
306
+ }
307
+
196
308
  async getFileEntries(runId) {
197
309
  return this.#db.get_file_entries.all({ run_id: runId });
198
310
  }
@@ -237,11 +349,13 @@ export default class KnownStore {
237
349
  }
238
350
 
239
351
  async setAttributes(runId, path, attrs) {
352
+ const normalized = KnownStore.normalizePath(path);
240
353
  await this.#db.update_entry_attributes.run({
241
354
  run_id: runId,
242
- path: KnownStore.normalizePath(path),
355
+ path: normalized,
243
356
  attributes: JSON.stringify(attrs),
244
357
  });
358
+ this.#emitChanged(runId, normalized, "attributes");
245
359
  }
246
360
 
247
361
  async getState(runId, path) {
@@ -14,7 +14,10 @@ export default class ProjectAgent {
14
14
  this.#db = db;
15
15
  this.#hooks = hooks;
16
16
  this.#llm = new LlmProvider(db);
17
- this.#knownStore = new KnownStore(db);
17
+ this.#knownStore = new KnownStore(db, {
18
+ onChanged: (event) => hooks.entry.changed.emit(event).catch(() => {}),
19
+ });
20
+ this.#knownStore.loadSchemes(db);
18
21
 
19
22
  const turnExecutor = new TurnExecutor(
20
23
  db,
@@ -1,10 +1,13 @@
1
1
  const MAX_STALLS = Number(process.env.RUMMY_MAX_STALLS) || 3;
2
2
  const MAX_REPETITIONS = Number(process.env.RUMMY_MAX_REPETITIONS) || 3;
3
+ const MAX_UPDATE_REPEATS = Number(process.env.RUMMY_MAX_UPDATE_REPEATS) || 3;
3
4
 
4
5
  export default class ResponseHealer {
5
6
  #stallCount = 0;
6
7
  #lastFingerprint = null;
7
8
  #repetitionCount = 0;
9
+ #lastUpdateText = null;
10
+ #updateRepeatCount = 0;
8
11
 
9
12
  /**
10
13
  * Heal a missing status tag. Called when the model emits
@@ -97,7 +100,7 @@ export default class ResponseHealer {
97
100
  * neither present → warn, increment stall counter, continue
98
101
  * stall counter hits MAX_STALLS → force-complete
99
102
  */
100
- assessProgress({ summaryText, updateText, statusHealed }) {
103
+ assessProgress({ summaryText, updateText, statusHealed, flags }) {
101
104
  if (summaryText) {
102
105
  this.#stallCount = 0;
103
106
  return { continue: false };
@@ -105,6 +108,21 @@ export default class ResponseHealer {
105
108
 
106
109
  if (updateText && !statusHealed) {
107
110
  this.#stallCount = 0;
111
+ // Track repeated update text — model stuck declaring readiness
112
+ // But if the model created new entries this turn, it's making
113
+ // progress even if the update text is the same.
114
+ const madeProgress = flags?.hasWrites || flags?.hasReads;
115
+ if (updateText === this.#lastUpdateText && !madeProgress) {
116
+ this.#updateRepeatCount++;
117
+ if (this.#updateRepeatCount >= MAX_UPDATE_REPEATS) {
118
+ const reason = `Same <update/> repeated ${this.#updateRepeatCount} turns: "${updateText.slice(0, 60)}"`;
119
+ console.warn(`[RUMMY] Stalled: ${reason}. Force-completing.`);
120
+ return { continue: false, reason };
121
+ }
122
+ } else {
123
+ this.#lastUpdateText = updateText;
124
+ this.#updateRepeatCount = 1;
125
+ }
108
126
  return { continue: true };
109
127
  }
110
128
 
@@ -130,5 +148,7 @@ export default class ResponseHealer {
130
148
  this.#stallCount = 0;
131
149
  this.#lastFingerprint = null;
132
150
  this.#repetitionCount = 0;
151
+ this.#lastUpdateText = null;
152
+ this.#updateRepeatCount = 0;
133
153
  }
134
154
  }