@possumtech/rummy 0.4.0 → 2.0.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 (153) hide show
  1. package/.env.example +21 -4
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -118
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +47 -8
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -77
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +52 -8
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -17
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -27
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -37
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -28
package/EXCEPTIONS.md DELETED
@@ -1,46 +0,0 @@
1
- # EXCEPTIONS.md — Documented Backbone Responsibilities
2
-
3
- Operations that bypass the plugin protocol. Each must justify WHY it
4
- can't go through the standard tool handler path. If the justification
5
- is weak, the exception should be eliminated.
6
-
7
- ## Resolved
8
-
9
- ### File.setConstraint / File.dropConstraint
10
-
11
- **What:** Direct DB writes to `file_constraints` table.
12
- **Justification:** File constraints are project-level config — they
13
- define which files a project cares about. This is backbone, not tool
14
- dispatch. Entry promotion/demotion that follows constraints now goes
15
- through the standard tool handler chain via `dispatchTool`.
16
- **Boundary documented:** SPEC.md §2.3.
17
-
18
- ## Currently Identified
19
-
20
- ### 1. TurnExecutor#record — tool-specific handling
21
-
22
- **What:** `known`, `unknown`, `summarize`, `update` have special-case
23
- code in `#record` (dedup, slug paths, lifecycle classification).
24
- **Bypasses:** These tools don't go through the same dispatch path as
25
- `get`, `set`, `rm` etc.
26
- **Justification:** Lifecycle signals (`summarize`, `update`) are state
27
- declarations, not tool operations — they always dispatch and cannot be
28
- 409'd. `known` and `unknown` generate their own paths from body content
29
- (slug paths). The classification is a fundamental architectural split
30
- (lifecycle vs action), not a protocol violation.
31
-
32
- ### 2. Token math — multiple measurement points
33
-
34
- **What:** `known_entries.tokens`, `turn_context.tokens`,
35
- `turns.context_tokens`, `countTokens()` estimates.
36
- **Bypasses:** No single function call, but a strict rule.
37
- **Justification:** Each serves a different purpose. `known_entries.tokens`
38
- is display-only (model sees entry sizes in `<knowns>`). `turn_context.tokens`
39
- is per-turn snapshot. `turns.context_tokens` is assembled ground truth for
40
- budget. The rule: budget decisions use ONLY assembled message tokens.
41
- DB tokens are NEVER used for budget. Documented in PLUGINS.md §7.5.
42
-
43
- ---
44
-
45
- *This file should shrink over time. Every entry is a debt to be paid
46
- or a boundary to be justified.*
@@ -1,338 +0,0 @@
1
- import slugify from "../sql/functions/slugify.js";
2
-
3
- export default class KnownStore {
4
- #db;
5
- #onChanged;
6
- #schemes = new Map();
7
- #seq = 0;
8
- #pendingResolutions = new Map();
9
-
10
- constructor(db, { onChanged } = {}) {
11
- this.#db = db;
12
- this.#onChanged = onChanged || null;
13
- }
14
-
15
- async loadSchemes(db) {
16
- const rows = await (db || this.#db).get_all_schemes.all();
17
- this.#schemes.clear();
18
- for (const row of rows) {
19
- this.#schemes.set(row.name, row);
20
- }
21
- }
22
-
23
- #emitChanged(runId, path, changeType) {
24
- if (this.#onChanged) this.#onChanged({ runId, path, changeType });
25
- }
26
-
27
- static scheme(path) {
28
- const idx = path.indexOf("://");
29
- return idx > 0 ? path.slice(0, idx) : null;
30
- }
31
-
32
- static normalizePath(path) {
33
- if (!path?.includes("://")) return path;
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
- }
44
- }
45
-
46
- async nextTurn(runId) {
47
- const row = await this.#db.next_turn.get({ run_id: runId });
48
- return row.turn;
49
- }
50
-
51
- async dedup(runId, scheme, target, turn) {
52
- const encodedTarget = encodeURIComponent(target);
53
- const turnPrefix = turn ? `turn_${turn}/` : "";
54
- const candidate = `${scheme}://${turnPrefix}${encodedTarget}`;
55
- const existing = await this.#db.get_entry_body.get({
56
- run_id: runId,
57
- path: candidate,
58
- });
59
- if (!existing) return candidate;
60
- return `${candidate}_${++this.#seq}`;
61
- }
62
-
63
- async slugPath(runId, scheme, content, summary) {
64
- const source = summary || content || "";
65
- const base = slugify(source);
66
- const prefix = `${scheme}://`;
67
-
68
- if (!base) return `${prefix}${++this.#seq}`;
69
-
70
- const candidate = `${prefix}${base}`;
71
- const existing = await this.#db.get_entry_body.get({
72
- run_id: runId,
73
- path: candidate,
74
- });
75
- if (!existing) return candidate;
76
-
77
- return `${prefix}${base}_${++this.#seq}`;
78
- }
79
-
80
- async upsert(
81
- runId,
82
- turn,
83
- path,
84
- body,
85
- status,
86
- {
87
- fidelity = "full",
88
- attributes = null,
89
- hash = null,
90
- updatedAt = null,
91
- loopId = null,
92
- } = {},
93
- ) {
94
- const normalized = KnownStore.normalizePath(path);
95
- await this.#db.upsert_known_entry.run({
96
- run_id: runId,
97
- loop_id: loopId,
98
- turn,
99
- path: normalized,
100
- body,
101
- status,
102
- fidelity,
103
- hash,
104
- attributes: attributes ? JSON.stringify(attributes) : null,
105
- updated_at: updatedAt,
106
- });
107
- this.#emitChanged(runId, normalized, "upsert");
108
- }
109
-
110
- async promote(runId, path, turn) {
111
- const normalized = KnownStore.normalizePath(path);
112
- await this.#db.promote_path.run({
113
- run_id: runId,
114
- path: normalized,
115
- turn,
116
- });
117
- this.#emitChanged(runId, normalized, "promote");
118
- }
119
-
120
- async setFileFidelity(runId, pattern, fidelity) {
121
- const result = await this.#db.set_file_fidelity.run({
122
- run_id: runId,
123
- pattern,
124
- fidelity,
125
- });
126
- if (result.changes === 0) {
127
- await this.upsert(runId, 0, pattern, "", 200, { fidelity });
128
- }
129
- this.#emitChanged(runId, pattern, "fidelity");
130
- }
131
-
132
- async setFidelity(runId, path, fidelity) {
133
- const normalized = KnownStore.normalizePath(path);
134
- await this.#db.set_fidelity.run({
135
- run_id: runId,
136
- path: normalized,
137
- fidelity,
138
- });
139
- this.#emitChanged(runId, normalized, "fidelity");
140
- }
141
-
142
- async demote(runId, path) {
143
- const normalized = KnownStore.normalizePath(path);
144
- await this.#db.demote_path.run({
145
- run_id: runId,
146
- path: normalized,
147
- });
148
- this.#emitChanged(runId, normalized, "demote");
149
- }
150
-
151
- async remove(runId, path) {
152
- const normalized = KnownStore.normalizePath(path);
153
- await this.#db.delete_known_entry.run({
154
- run_id: runId,
155
- path: normalized,
156
- });
157
- this.#emitChanged(runId, normalized, "remove");
158
- }
159
-
160
- async removeFilesByPattern(runId, pattern) {
161
- await this.#db.delete_file_entries_by_pattern.run({
162
- run_id: runId,
163
- pattern,
164
- });
165
- this.#emitChanged(runId, pattern, "remove");
166
- }
167
-
168
- static #bodyPattern(body) {
169
- return body || null;
170
- }
171
-
172
- async promoteByPattern(runId, path, body, turn) {
173
- await this.#db.promote_by_pattern.run({
174
- run_id: runId,
175
- path,
176
- body: KnownStore.#bodyPattern(body),
177
- turn,
178
- });
179
- this.#emitChanged(runId, path, "promote");
180
- }
181
-
182
- async demoteByPattern(runId, path, body) {
183
- await this.#db.demote_by_pattern.run({
184
- run_id: runId,
185
- path,
186
- body: KnownStore.#bodyPattern(body),
187
- });
188
- this.#emitChanged(runId, path, "demote");
189
- }
190
-
191
- async getEntriesByPattern(runId, path, body, { limit, offset } = {}) {
192
- return this.#db.get_entries_by_pattern.all({
193
- run_id: runId,
194
- path,
195
- body: KnownStore.#bodyPattern(body),
196
- limit: limit ?? null,
197
- offset: offset ?? null,
198
- });
199
- }
200
-
201
- async deleteByPattern(runId, path, body) {
202
- await this.#db.delete_entries_by_pattern.run({
203
- run_id: runId,
204
- path,
205
- body: KnownStore.#bodyPattern(body),
206
- });
207
- this.#emitChanged(runId, path, "remove");
208
- }
209
-
210
- async updateBodyByPattern(runId, path, body, newBody) {
211
- await this.#db.update_body_by_pattern.run({
212
- run_id: runId,
213
- path,
214
- body: KnownStore.#bodyPattern(body),
215
- new_body: newBody,
216
- });
217
- this.#emitChanged(runId, path, "body");
218
- }
219
-
220
- async resolve(runId, path, status, body) {
221
- const normalized = KnownStore.normalizePath(path);
222
- await this.#db.resolve_known_entry.run({
223
- run_id: runId,
224
- path: normalized,
225
- status,
226
- body,
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
- });
243
- }
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
-
251
- async getLog(runId) {
252
- return this.#db.get_results.all({ run_id: runId });
253
- }
254
-
255
- async getEntries(runId) {
256
- return this.#db.get_known_entries.all({ run_id: runId });
257
- }
258
-
259
- async getFileEntries(runId) {
260
- return this.#db.get_file_entries.all({ run_id: runId });
261
- }
262
-
263
- async getFileStatesByPattern(runId, pattern) {
264
- return this.#db.get_file_states_by_pattern.all({ run_id: runId, pattern });
265
- }
266
-
267
- async hasRejections(runId, loopId) {
268
- const row = await this.#db.has_rejections.get({
269
- run_id: runId,
270
- loop_id: loopId,
271
- });
272
- return row.count > 0;
273
- }
274
-
275
- async hasAcceptedActions(runId) {
276
- const row = await this.#db.has_accepted_actions.get({ run_id: runId });
277
- return row.count > 0;
278
- }
279
-
280
- async getUnresolved(runId) {
281
- return this.#db.get_unresolved.all({ run_id: runId });
282
- }
283
-
284
- async countUnknowns(runId) {
285
- const row = await this.#db.count_unknowns.get({ run_id: runId });
286
- return row.count;
287
- }
288
-
289
- async getUnknownValues(runId) {
290
- const rows = await this.#db.get_unknown_values.all({ run_id: runId });
291
- return new Set(rows.map((r) => r.body));
292
- }
293
-
294
- async getBody(runId, path) {
295
- const row = await this.#db.get_entry_body.get({
296
- run_id: runId,
297
- path: KnownStore.normalizePath(path),
298
- });
299
- return row?.body ?? null;
300
- }
301
-
302
- async setAttributes(runId, path, attrs) {
303
- const normalized = KnownStore.normalizePath(path);
304
- await this.#db.update_entry_attributes.run({
305
- run_id: runId,
306
- path: normalized,
307
- attributes: JSON.stringify(attrs),
308
- });
309
- this.#emitChanged(runId, normalized, "attributes");
310
- }
311
-
312
- async getState(runId, path) {
313
- return this.#db.get_entry_state.get({
314
- run_id: runId,
315
- path: KnownStore.normalizePath(path),
316
- });
317
- }
318
-
319
- async getAttributes(runId, path) {
320
- const row = await this.#db.get_entry_attributes.get({
321
- run_id: runId,
322
- path: KnownStore.normalizePath(path),
323
- });
324
- return row?.attributes ? JSON.parse(row.attributes) : null;
325
- }
326
-
327
- async getTurnAudit(runId, turn) {
328
- return this.#db.get_turn_audit.all({ run_id: runId, turn });
329
- }
330
-
331
- static toolFromPath(path) {
332
- return KnownStore.scheme(path);
333
- }
334
-
335
- static isSystemPath(path) {
336
- return path.includes("://");
337
- }
338
- }
@@ -1,188 +0,0 @@
1
- const MAX_STALLS = Number(process.env.RUMMY_MAX_STALLS) || 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;
4
- const MAX_UPDATE_REPEATS = Number(process.env.RUMMY_MAX_UPDATE_REPEATS) || 3;
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
-
50
- export default class ResponseHealer {
51
- #stallCount = 0;
52
- #turnHistory = [];
53
- #lastUpdateText = null;
54
- #updateRepeatCount = 0;
55
-
56
- /**
57
- * Heal a missing status tag. Called when the model emits
58
- * neither <summarize/> nor <update/>.
59
- */
60
- /**
61
- * Heal a missing status tag. Called when the model emits
62
- * neither <summarize/> nor <update/>.
63
- *
64
- * Plain text with no commands = the model answered. Treat as summary.
65
- * Commands with no status tag = the model is working. Treat as update.
66
- */
67
- static healStatus(content, commands) {
68
- const trimmed = content.trim();
69
-
70
- // No commands + plain text = answered. Treat as summary.
71
- if (commands.length === 0 && trimmed) {
72
- console.warn("[RUMMY] Healed: plain text response treated as summary");
73
- return { summaryText: trimmed.slice(0, 500), updateText: null };
74
- }
75
-
76
- // Only write/unknown commands + no investigation tools = completed action.
77
- // The model did the thing without saying <summarize>. Treat as summary.
78
- const hasInvestigation = commands.some((c) =>
79
- ["get", "env", "search", "ask_user"].includes(c.name),
80
- );
81
- if (!hasInvestigation && commands.length > 0) {
82
- const names = commands.map((c) => c.name).join(", ");
83
- console.warn(
84
- `[RUMMY] Healed: action-only response (${names}) treated as summary`,
85
- );
86
- return {
87
- summaryText: trimmed.slice(0, 500) || "Done.",
88
- updateText: null,
89
- };
90
- }
91
-
92
- console.warn(
93
- `[RUMMY] Healed: missing <update>/<summarize>. Tools: ${commands.map((c) => c.name).join(", ") || "none"}`,
94
- );
95
- return { summaryText: null, updateText: "..." };
96
- }
97
-
98
- /**
99
- * Detect cyclic tool patterns across turns.
100
- * Returns { continue: boolean, reason?: string }
101
- *
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.
108
- */
109
- assessRepetition({ actionCalls, writeCalls }) {
110
- const commands = [...(actionCalls || []), ...(writeCalls || [])];
111
- if (commands.length === 0) return { continue: true };
112
-
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 };
121
- }
122
-
123
- return { continue: true };
124
- }
125
-
126
- /**
127
- * Assess whether the run should continue.
128
- *
129
- * Returns { continue: boolean, reason?: string }
130
- *
131
- * Rules:
132
- * <summarize/> present → done (terminate)
133
- * <summarize/> + failed actions → overridden to <update> (continue)
134
- * <update/> present → continue (model says it's working)
135
- * neither present → warn, increment stall counter, continue
136
- * stall counter hits MAX_STALLS → force-complete
137
- */
138
- assessProgress({ summaryText, updateText, statusHealed, flags }) {
139
- if (summaryText) {
140
- this.#stallCount = 0;
141
- return { continue: false };
142
- }
143
-
144
- if (updateText && !statusHealed) {
145
- this.#stallCount = 0;
146
- // Track repeated update text — model stuck declaring readiness
147
- // But if the model created new entries this turn, it's making
148
- // progress even if the update text is the same.
149
- const madeProgress = flags?.hasWrites || flags?.hasReads;
150
- if (updateText === this.#lastUpdateText && !madeProgress) {
151
- this.#updateRepeatCount++;
152
- if (this.#updateRepeatCount >= MAX_UPDATE_REPEATS) {
153
- const reason = `Same <update/> repeated ${this.#updateRepeatCount} turns: "${updateText.slice(0, 60)}"`;
154
- console.warn(`[RUMMY] Stalled: ${reason}. Force-completing.`);
155
- return { continue: false, reason };
156
- }
157
- } else {
158
- this.#lastUpdateText = updateText;
159
- this.#updateRepeatCount = 1;
160
- }
161
- return { continue: true };
162
- }
163
-
164
- // Healed or neither — model is glitching
165
- this.#stallCount++;
166
-
167
- if (this.#stallCount >= MAX_STALLS) {
168
- const reason = `${this.#stallCount} turns with no <update/> or <summarize/>`;
169
- console.warn(`[RUMMY] Stalled: ${reason}. Force-completing.`);
170
- return { continue: false, reason };
171
- }
172
-
173
- console.warn(
174
- `[RUMMY] No <update/> or <summarize/> (stall ${this.#stallCount}/${MAX_STALLS})`,
175
- );
176
- return { continue: true };
177
- }
178
-
179
- /**
180
- * Reset state for a new run or after resolution resume.
181
- */
182
- reset() {
183
- this.#stallCount = 0;
184
- this.#turnHistory = [];
185
- this.#lastUpdateText = null;
186
- this.#updateRepeatCount = 0;
187
- }
188
- }
@@ -1,100 +0,0 @@
1
- import msg from "../agent/messages.js";
2
-
3
- export default class OpenAiClient {
4
- #baseUrl;
5
- #apiKey;
6
-
7
- constructor(baseUrl, apiKey) {
8
- this.#baseUrl = String(baseUrl || "").replace(/\/v1\/?$/, "");
9
- this.#apiKey = apiKey || "";
10
- }
11
-
12
- async completion(messages, model, options = {}) {
13
- const body = { model, messages, think: true };
14
- if (options.temperature !== undefined)
15
- body.temperature = options.temperature;
16
-
17
- const timeout = Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000;
18
- const timeoutSignal = AbortSignal.timeout(timeout);
19
- const signal = options.signal
20
- ? AbortSignal.any([options.signal, timeoutSignal])
21
- : timeoutSignal;
22
-
23
- const headers = { "Content-Type": "application/json" };
24
- if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
25
-
26
- const response = await fetch(`${this.#baseUrl}/v1/chat/completions`, {
27
- method: "POST",
28
- headers,
29
- body: JSON.stringify(body),
30
- signal,
31
- });
32
-
33
- if (!response.ok) {
34
- const error = await response.text();
35
- throw new Error(
36
- msg("error.openai_api", { status: `${response.status} - ${error}` }),
37
- );
38
- }
39
-
40
- const data = await response.json();
41
-
42
- for (const choice of data.choices || []) {
43
- const msg = choice.message;
44
- if (!msg) continue;
45
-
46
- // Normalize reasoning
47
- const parts = [msg.reasoning_content, msg.reasoning, msg.thinking].filter(
48
- Boolean,
49
- );
50
- msg.reasoning_content =
51
- parts.length > 0 ? [...new Set(parts)].join("\n") : null;
52
-
53
- if (process.env.RUMMY_DEBUG === "true" && msg.reasoning_content) {
54
- console.warn(
55
- `[RUMMY] Reasoning (${msg.reasoning_content.length} chars): ${msg.reasoning_content.slice(0, 120)}`,
56
- );
57
- }
58
- }
59
-
60
- return data;
61
- }
62
-
63
- async getContextSize(_model) {
64
- const timeout = Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000;
65
- const headers = { "Content-Type": "application/json" };
66
- if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
67
-
68
- // Try /props first — llama.cpp exposes runtime n_ctx here
69
- try {
70
- const propsResponse = await fetch(`${this.#baseUrl}/props`, {
71
- headers,
72
- signal: AbortSignal.timeout(timeout),
73
- });
74
- if (propsResponse.ok) {
75
- const props = await propsResponse.json();
76
- const runtimeCtx = props?.default_generation_settings?.n_ctx;
77
- if (runtimeCtx) return runtimeCtx;
78
- }
79
- } catch {}
80
-
81
- // Fall back to /v1/models for training context
82
- const response = await fetch(`${this.#baseUrl}/v1/models`, {
83
- headers,
84
- signal: AbortSignal.timeout(timeout),
85
- });
86
- if (!response.ok) {
87
- throw new Error(
88
- msg("error.openai_models_failed", {
89
- status: response.status,
90
- baseUrl: this.#baseUrl,
91
- }),
92
- );
93
- }
94
- const data = await response.json();
95
- const model = data.data?.[0];
96
- const ctx = model?.meta?.n_ctx_train || model?.context_length;
97
- if (!ctx) throw new Error(msg("error.openai_no_context_length"));
98
- return ctx;
99
- }
100
- }