@possumtech/rummy 0.3.0 → 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 (65) hide show
  1. package/.env.example +13 -1
  2. package/PLUGINS.md +1 -1
  3. package/README.md +5 -1
  4. package/SPEC.md +211 -54
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +7 -3
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +183 -238
  9. package/src/agent/ContextAssembler.js +2 -0
  10. package/src/agent/KnownStore.js +36 -85
  11. package/src/agent/ResponseHealer.js +65 -31
  12. package/src/agent/TurnExecutor.js +284 -382
  13. package/src/agent/XmlParser.js +28 -4
  14. package/src/agent/known_queries.sql +1 -1
  15. package/src/agent/known_store.sql +32 -34
  16. package/src/agent/runs.sql +2 -2
  17. package/src/agent/tokens.js +1 -0
  18. package/src/agent/turns.sql +5 -0
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +2 -4
  21. package/src/hooks/ToolRegistry.js +8 -13
  22. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  23. package/src/plugins/budget/README.md +26 -30
  24. package/src/plugins/budget/budget.js +69 -36
  25. package/src/plugins/budget/recovery.js +47 -0
  26. package/src/plugins/cp/cp.js +1 -1
  27. package/src/plugins/cp/cpDoc.js +5 -10
  28. package/src/plugins/env/envDoc.js +3 -8
  29. package/src/plugins/get/get.js +70 -2
  30. package/src/plugins/get/getDoc.js +19 -16
  31. package/src/plugins/hedberg/matcher.js +10 -29
  32. package/src/plugins/helpers.js +2 -2
  33. package/src/plugins/instructions/instructions.js +3 -2
  34. package/src/plugins/instructions/preamble.md +33 -12
  35. package/src/plugins/known/known.js +66 -17
  36. package/src/plugins/known/knownDoc.js +7 -10
  37. package/src/plugins/mv/mv.js +18 -1
  38. package/src/plugins/mv/mvDoc.js +9 -10
  39. package/src/plugins/{current → performed}/README.md +4 -3
  40. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  41. package/src/plugins/policy/policy.js +47 -0
  42. package/src/plugins/previous/README.md +2 -1
  43. package/src/plugins/previous/previous.js +31 -25
  44. package/src/plugins/progress/README.md +1 -2
  45. package/src/plugins/progress/progress.js +10 -60
  46. package/src/plugins/prompt/prompt.js +10 -8
  47. package/src/plugins/rm/rm.js +27 -15
  48. package/src/plugins/rm/rmDoc.js +6 -11
  49. package/src/plugins/rpc/rpc.js +3 -1
  50. package/src/plugins/set/set.js +125 -92
  51. package/src/plugins/set/setDoc.js +28 -37
  52. package/src/plugins/sh/shDoc.js +2 -7
  53. package/src/plugins/summarize/summarize.js +7 -0
  54. package/src/plugins/summarize/summarizeDoc.js +6 -11
  55. package/src/plugins/telemetry/telemetry.js +14 -9
  56. package/src/plugins/think/think.js +12 -0
  57. package/src/plugins/think/thinkDoc.js +18 -0
  58. package/src/plugins/unknown/README.md +2 -1
  59. package/src/plugins/unknown/unknown.js +26 -4
  60. package/src/plugins/unknown/unknownDoc.js +9 -14
  61. package/src/plugins/update/update.js +7 -0
  62. package/src/plugins/update/updateDoc.js +6 -11
  63. package/src/server/ClientConnection.js +69 -45
  64. package/src/sql/v_model_context.sql +7 -17
  65. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -1,25 +1,17 @@
1
1
  import slugify from "../sql/functions/slugify.js";
2
- import { countTokens } from "./tokens.js";
3
2
 
4
3
  export default class KnownStore {
5
4
  #db;
6
5
  #onChanged;
7
- #budgetGuard = null;
8
6
  #schemes = new Map();
7
+ #seq = 0;
8
+ #pendingResolutions = new Map();
9
9
 
10
10
  constructor(db, { onChanged } = {}) {
11
11
  this.#db = db;
12
12
  this.#onChanged = onChanged || null;
13
13
  }
14
14
 
15
- get budgetGuard() {
16
- return this.#budgetGuard;
17
- }
18
-
19
- set budgetGuard(guard) {
20
- this.#budgetGuard = guard;
21
- }
22
-
23
15
  async loadSchemes(db) {
24
16
  const rows = await (db || this.#db).get_all_schemes.all();
25
17
  this.#schemes.clear();
@@ -28,13 +20,6 @@ export default class KnownStore {
28
20
  }
29
21
  }
30
22
 
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
23
  #emitChanged(runId, path, changeType) {
39
24
  if (this.#onChanged) this.#onChanged({ runId, path, changeType });
40
25
  }
@@ -46,15 +31,16 @@ export default class KnownStore {
46
31
 
47
32
  static normalizePath(path) {
48
33
  if (!path?.includes("://")) return path;
49
- return path.replace(/:\/\/(.*)$/, (_, rest) => {
50
- try {
51
- // Decode first (idempotent), then encode — but preserve slashes
52
- const decoded = decodeURIComponent(rest);
53
- return `://${decoded.split("/").map(encodeURIComponent).join("/")}`;
54
- } catch {
55
- return `://${rest.split("/").map(encodeURIComponent).join("/")}`;
56
- }
57
- });
34
+ const sep = path.indexOf("://");
35
+ const scheme = path.slice(0, sep).toLowerCase();
36
+ const rest = path.slice(sep + 3);
37
+ try {
38
+ // Decode first (idempotent), then encode — but preserve slashes
39
+ const decoded = decodeURIComponent(rest);
40
+ return `${scheme}://${decoded.split("/").map(encodeURIComponent).join("/")}`;
41
+ } catch {
42
+ return `${scheme}://${rest.split("/").map(encodeURIComponent).join("/")}`;
43
+ }
58
44
  }
59
45
 
60
46
  async nextTurn(runId) {
@@ -71,15 +57,15 @@ export default class KnownStore {
71
57
  path: candidate,
72
58
  });
73
59
  if (!existing) return candidate;
74
- return `${candidate}_${Date.now()}`;
60
+ return `${candidate}_${++this.#seq}`;
75
61
  }
76
62
 
77
63
  async slugPath(runId, scheme, content, summary) {
78
- const source = summary ? summary.replace(/,\s*/g, "/") : content || "";
64
+ const source = summary || content || "";
79
65
  const base = slugify(source);
80
66
  const prefix = `${scheme}://`;
81
67
 
82
- if (!base) return `${prefix}${Date.now()}`;
68
+ if (!base) return `${prefix}${++this.#seq}`;
83
69
 
84
70
  const candidate = `${prefix}${base}`;
85
71
  const existing = await this.#db.get_entry_body.get({
@@ -88,7 +74,7 @@ export default class KnownStore {
88
74
  });
89
75
  if (!existing) return candidate;
90
76
 
91
- return `${prefix}${base}_${Date.now()}`;
77
+ return `${prefix}${base}_${++this.#seq}`;
92
78
  }
93
79
 
94
80
  async upsert(
@@ -106,22 +92,6 @@ export default class KnownStore {
106
92
  } = {},
107
93
  ) {
108
94
  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
-
125
95
  await this.#db.upsert_known_entry.run({
126
96
  run_id: runId,
127
97
  loop_id: loopId,
@@ -135,8 +105,6 @@ export default class KnownStore {
135
105
  updated_at: updatedAt,
136
106
  });
137
107
  this.#emitChanged(runId, normalized, "upsert");
138
-
139
- if (delta > 0) this.#budgetGuard?.charge(delta);
140
108
  }
141
109
 
142
110
  async promote(runId, path, turn) {
@@ -202,21 +170,6 @@ export default class KnownStore {
202
170
  }
203
171
 
204
172
  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
-
220
173
  await this.#db.promote_by_pattern.run({
221
174
  run_id: runId,
222
175
  path,
@@ -224,8 +177,6 @@ export default class KnownStore {
224
177
  turn,
225
178
  });
226
179
  this.#emitChanged(runId, path, "promote");
227
-
228
- if (cost > 0) this.#budgetGuard?.charge(cost);
229
180
  }
230
181
 
231
182
  async demoteByPattern(runId, path, body) {
@@ -257,24 +208,6 @@ export default class KnownStore {
257
208
  }
258
209
 
259
210
  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
-
278
211
  await this.#db.update_body_by_pattern.run({
279
212
  run_id: runId,
280
213
  path,
@@ -282,8 +215,6 @@ export default class KnownStore {
282
215
  new_body: newBody,
283
216
  });
284
217
  this.#emitChanged(runId, path, "body");
285
-
286
- if (delta > 0) this.#budgetGuard?.charge(delta);
287
218
  }
288
219
 
289
220
  async resolve(runId, path, status, body) {
@@ -295,8 +226,28 @@ export default class KnownStore {
295
226
  body,
296
227
  });
297
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
+ });
298
243
  }
299
244
 
245
+ async restoreSummarizedPrompts(runId) {
246
+ await this.#db.restore_summarized_prompts.run({ run_id: runId });
247
+ this.#emitChanged(runId, "prompt://batch", "fidelity");
248
+ }
249
+
250
+
300
251
  async getLog(runId) {
301
252
  return this.#db.get_results.all({ run_id: runId });
302
253
  }
@@ -1,11 +1,55 @@
1
1
  const MAX_STALLS = Number(process.env.RUMMY_MAX_STALLS) || 3;
2
- const MAX_REPETITIONS = Number(process.env.RUMMY_MAX_REPETITIONS) || 3;
2
+ const MIN_CYCLES = Number(process.env.RUMMY_MIN_CYCLES) || 3;
3
+ const MAX_CYCLE_PERIOD = Number(process.env.RUMMY_MAX_CYCLE_PERIOD) || 4;
3
4
  const MAX_UPDATE_REPEATS = Number(process.env.RUMMY_MAX_UPDATE_REPEATS) || 3;
4
5
 
6
+ /**
7
+ * Build a stable fingerprint for a single recorded entry.
8
+ * Uses scheme + original command target + all op-defining attributes.
9
+ * Excludes body (content too granular; same operation ≠ same content).
10
+ */
11
+ function cmdFingerprint(entry) {
12
+ const attrs = { ...(entry.attributes ?? {}) };
13
+ delete attrs.body;
14
+ const target =
15
+ attrs.path ?? attrs.command ?? attrs.query ?? attrs.question ?? "";
16
+ delete attrs.path;
17
+ const extra = Object.keys(attrs)
18
+ .toSorted()
19
+ .filter((k) => attrs[k] != null)
20
+ .map((k) => `${k}=${attrs[k]}`)
21
+ .join(",");
22
+ return `${entry.scheme}:${target}${extra ? `[${extra}]` : ""}`;
23
+ }
24
+
25
+ /**
26
+ * Detect a repeating cycle in the fingerprint history.
27
+ * Checks periods 1..MAX_CYCLE_PERIOD for MIN_CYCLES consecutive repetitions.
28
+ * Catches AAAA (period 1), ABABAB (period 2), ABCABCABC (period 3), etc.
29
+ */
30
+ function detectCycle(history) {
31
+ for (let k = 1; k <= MAX_CYCLE_PERIOD; k++) {
32
+ const needed = k * MIN_CYCLES;
33
+ if (history.length < needed) continue;
34
+ const tail = history.slice(-needed);
35
+ const cycle = tail.slice(0, k);
36
+ let match = true;
37
+ outer: for (let rep = 0; rep < MIN_CYCLES; rep++) {
38
+ for (let j = 0; j < k; j++) {
39
+ if (tail[rep * k + j] !== cycle[j]) {
40
+ match = false;
41
+ break outer;
42
+ }
43
+ }
44
+ }
45
+ if (match) return { detected: true, period: k, cycles: MIN_CYCLES };
46
+ }
47
+ return { detected: false };
48
+ }
49
+
5
50
  export default class ResponseHealer {
6
51
  #stallCount = 0;
7
- #lastFingerprint = null;
8
- #repetitionCount = 0;
52
+ #turnHistory = [];
9
53
  #lastUpdateText = null;
10
54
  #updateRepeatCount = 0;
11
55
 
@@ -52,38 +96,28 @@ export default class ResponseHealer {
52
96
  }
53
97
 
54
98
  /**
55
- * Check for repeated tool commands across turns.
99
+ * Detect cyclic tool patterns across turns.
56
100
  * Returns { continue: boolean, reason?: string }
57
101
  *
58
- * Fingerprints the commands (name + path/query). If the same fingerprint
59
- * repeats for MAX_REPETITIONS consecutive turns, force-complete.
102
+ * Appends this turn's fingerprint to history, then checks whether the
103
+ * history ends in a repeating cycle of period 1..MAX_CYCLE_PERIOD with
104
+ * at least MIN_CYCLES consecutive repetitions.
105
+ *
106
+ * Catches AAAA (period 1), ABABAB (period 2), ABCABC (period 3), etc.
107
+ * Turns with no tool calls are skipped — they don't contribute to a cycle.
60
108
  */
61
109
  assessRepetition({ actionCalls, writeCalls }) {
62
110
  const commands = [...(actionCalls || []), ...(writeCalls || [])];
63
- if (commands.length === 0) {
64
- this.#lastFingerprint = null;
65
- this.#repetitionCount = 0;
66
- return { continue: true };
67
- }
111
+ if (commands.length === 0) return { continue: true };
68
112
 
69
- const fingerprint = commands
70
- .map((c) => `${c.name}:${c.path || c.command || c.question || ""}`)
71
- .toSorted()
72
- .join("|");
73
-
74
- if (fingerprint === this.#lastFingerprint) {
75
- this.#repetitionCount++;
76
- if (this.#repetitionCount >= MAX_REPETITIONS) {
77
- const reason = `Same commands repeated ${this.#repetitionCount} turns`;
78
- console.warn(`[RUMMY] Loop detected: ${reason}. Force-completing.`);
79
- return { continue: false, reason };
80
- }
81
- console.warn(
82
- `[RUMMY] Repeated commands (${this.#repetitionCount}/${MAX_REPETITIONS}): ${fingerprint.slice(0, 80)}`,
83
- );
84
- } else {
85
- this.#repetitionCount = 1;
86
- this.#lastFingerprint = fingerprint;
113
+ const fp = commands.map(cmdFingerprint).toSorted().join("|");
114
+ this.#turnHistory.push(fp);
115
+
116
+ const cycle = detectCycle(this.#turnHistory);
117
+ if (cycle.detected) {
118
+ const reason = `Cyclic tool pattern (period ${cycle.period}, ${cycle.cycles} repetitions)`;
119
+ console.warn(`[RUMMY] Loop detected: ${reason}. Force-completing.`);
120
+ return { continue: false, reason };
87
121
  }
88
122
 
89
123
  return { continue: true };
@@ -96,6 +130,7 @@ export default class ResponseHealer {
96
130
  *
97
131
  * Rules:
98
132
  * <summarize/> present → done (terminate)
133
+ * <summarize/> + failed actions → overridden to <update> (continue)
99
134
  * <update/> present → continue (model says it's working)
100
135
  * neither present → warn, increment stall counter, continue
101
136
  * stall counter hits MAX_STALLS → force-complete
@@ -146,8 +181,7 @@ export default class ResponseHealer {
146
181
  */
147
182
  reset() {
148
183
  this.#stallCount = 0;
149
- this.#lastFingerprint = null;
150
- this.#repetitionCount = 0;
184
+ this.#turnHistory = [];
151
185
  this.#lastUpdateText = null;
152
186
  this.#updateRepeatCount = 0;
153
187
  }