@possumtech/rummy 0.2.1

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 (120) hide show
  1. package/.env.example +55 -0
  2. package/LICENSE +21 -0
  3. package/PLUGINS.md +302 -0
  4. package/README.md +41 -0
  5. package/SPEC.md +524 -0
  6. package/lang/en.json +34 -0
  7. package/migrations/001_initial_schema.sql +226 -0
  8. package/package.json +54 -0
  9. package/service.js +143 -0
  10. package/src/agent/AgentLoop.js +553 -0
  11. package/src/agent/ContextAssembler.js +29 -0
  12. package/src/agent/KnownStore.js +254 -0
  13. package/src/agent/ProjectAgent.js +101 -0
  14. package/src/agent/ResponseHealer.js +134 -0
  15. package/src/agent/TurnExecutor.js +457 -0
  16. package/src/agent/XmlParser.js +247 -0
  17. package/src/agent/known_checks.sql +42 -0
  18. package/src/agent/known_queries.sql +80 -0
  19. package/src/agent/known_store.sql +161 -0
  20. package/src/agent/messages.js +17 -0
  21. package/src/agent/prompt_queue.sql +39 -0
  22. package/src/agent/runs.sql +114 -0
  23. package/src/agent/schemes.sql +3 -0
  24. package/src/agent/sessions.sql +51 -0
  25. package/src/agent/tokens.js +28 -0
  26. package/src/agent/turns.sql +36 -0
  27. package/src/hooks/HookRegistry.js +72 -0
  28. package/src/hooks/Hooks.js +115 -0
  29. package/src/hooks/PluginContext.js +116 -0
  30. package/src/hooks/RummyContext.js +181 -0
  31. package/src/hooks/ToolRegistry.js +83 -0
  32. package/src/llm/LlmProvider.js +107 -0
  33. package/src/llm/OllamaClient.js +88 -0
  34. package/src/llm/OpenAiClient.js +80 -0
  35. package/src/llm/OpenRouterClient.js +78 -0
  36. package/src/llm/XaiClient.js +113 -0
  37. package/src/plugins/ask_user/README.md +18 -0
  38. package/src/plugins/ask_user/ask_user.js +48 -0
  39. package/src/plugins/ask_user/docs.md +2 -0
  40. package/src/plugins/cp/README.md +18 -0
  41. package/src/plugins/cp/cp.js +55 -0
  42. package/src/plugins/cp/docs.md +2 -0
  43. package/src/plugins/current/README.md +14 -0
  44. package/src/plugins/current/current.js +48 -0
  45. package/src/plugins/engine/README.md +12 -0
  46. package/src/plugins/engine/engine.sql +18 -0
  47. package/src/plugins/engine/turn_context.sql +51 -0
  48. package/src/plugins/env/README.md +14 -0
  49. package/src/plugins/env/docs.md +2 -0
  50. package/src/plugins/env/env.js +32 -0
  51. package/src/plugins/file/README.md +25 -0
  52. package/src/plugins/file/file.js +85 -0
  53. package/src/plugins/get/README.md +19 -0
  54. package/src/plugins/get/docs.md +6 -0
  55. package/src/plugins/get/get.js +53 -0
  56. package/src/plugins/hedberg/README.md +72 -0
  57. package/src/plugins/hedberg/docs.md +9 -0
  58. package/src/plugins/hedberg/edits.js +65 -0
  59. package/src/plugins/hedberg/hedberg.js +89 -0
  60. package/src/plugins/hedberg/matcher.js +181 -0
  61. package/src/plugins/hedberg/normalize.js +41 -0
  62. package/src/plugins/hedberg/patterns.js +452 -0
  63. package/src/plugins/hedberg/sed.js +48 -0
  64. package/src/plugins/helpers.js +22 -0
  65. package/src/plugins/index.js +180 -0
  66. package/src/plugins/instructions/README.md +11 -0
  67. package/src/plugins/instructions/instructions.js +37 -0
  68. package/src/plugins/instructions/preamble.md +12 -0
  69. package/src/plugins/known/README.md +18 -0
  70. package/src/plugins/known/docs.md +3 -0
  71. package/src/plugins/known/known.js +57 -0
  72. package/src/plugins/mv/README.md +18 -0
  73. package/src/plugins/mv/docs.md +2 -0
  74. package/src/plugins/mv/mv.js +56 -0
  75. package/src/plugins/previous/README.md +15 -0
  76. package/src/plugins/previous/previous.js +50 -0
  77. package/src/plugins/progress/README.md +17 -0
  78. package/src/plugins/progress/progress.js +44 -0
  79. package/src/plugins/prompt/README.md +16 -0
  80. package/src/plugins/prompt/prompt.js +45 -0
  81. package/src/plugins/rm/README.md +18 -0
  82. package/src/plugins/rm/docs.md +4 -0
  83. package/src/plugins/rm/rm.js +51 -0
  84. package/src/plugins/rpc/README.md +45 -0
  85. package/src/plugins/rpc/rpc.js +587 -0
  86. package/src/plugins/set/README.md +32 -0
  87. package/src/plugins/set/docs.md +4 -0
  88. package/src/plugins/set/set.js +268 -0
  89. package/src/plugins/sh/README.md +18 -0
  90. package/src/plugins/sh/docs.md +2 -0
  91. package/src/plugins/sh/sh.js +32 -0
  92. package/src/plugins/skills/README.md +25 -0
  93. package/src/plugins/skills/skills.js +175 -0
  94. package/src/plugins/store/README.md +20 -0
  95. package/src/plugins/store/docs.md +5 -0
  96. package/src/plugins/store/store.js +52 -0
  97. package/src/plugins/summarize/README.md +18 -0
  98. package/src/plugins/summarize/docs.md +4 -0
  99. package/src/plugins/summarize/summarize.js +24 -0
  100. package/src/plugins/telemetry/README.md +19 -0
  101. package/src/plugins/telemetry/rpc_log.sql +28 -0
  102. package/src/plugins/telemetry/telemetry.js +186 -0
  103. package/src/plugins/unknown/README.md +23 -0
  104. package/src/plugins/unknown/docs.md +5 -0
  105. package/src/plugins/unknown/unknown.js +31 -0
  106. package/src/plugins/update/README.md +18 -0
  107. package/src/plugins/update/docs.md +4 -0
  108. package/src/plugins/update/update.js +24 -0
  109. package/src/server/ClientConnection.js +228 -0
  110. package/src/server/RpcRegistry.js +52 -0
  111. package/src/server/SocketServer.js +43 -0
  112. package/src/sql/file_constraints.sql +15 -0
  113. package/src/sql/functions/countTokens.js +7 -0
  114. package/src/sql/functions/hedmatch.js +8 -0
  115. package/src/sql/functions/hedreplace.js +8 -0
  116. package/src/sql/functions/hedsearch.js +8 -0
  117. package/src/sql/functions/schemeOf.js +7 -0
  118. package/src/sql/functions/slugify.js +6 -0
  119. package/src/sql/v_model_context.sql +101 -0
  120. package/src/sql/v_run_log.sql +23 -0
@@ -0,0 +1,254 @@
1
+ import slugify from "../sql/functions/slugify.js";
2
+
3
+ export default class KnownStore {
4
+ #db;
5
+
6
+ constructor(db) {
7
+ this.#db = db;
8
+ }
9
+
10
+ static scheme(path) {
11
+ const idx = path.indexOf("://");
12
+ return idx > 0 ? path.slice(0, idx) : null;
13
+ }
14
+
15
+ static normalizePath(path) {
16
+ if (!path?.includes("://")) return path;
17
+ return path.replace(/:\/\/(.*)$/, (_, rest) => {
18
+ try {
19
+ // Decode first (idempotent), then encode — but preserve slashes
20
+ const decoded = decodeURIComponent(rest);
21
+ return `://${decoded.split("/").map(encodeURIComponent).join("/")}`;
22
+ } catch {
23
+ return `://${rest.split("/").map(encodeURIComponent).join("/")}`;
24
+ }
25
+ });
26
+ }
27
+
28
+ async nextTurn(runId) {
29
+ const row = await this.#db.next_turn.get({ run_id: runId });
30
+ return row.turn;
31
+ }
32
+
33
+ async dedup(runId, scheme, target) {
34
+ const candidate = `${scheme}://${target}`;
35
+ const existing = await this.#db.get_entry_body.get({
36
+ run_id: runId,
37
+ path: KnownStore.normalizePath(candidate),
38
+ });
39
+ if (!existing) return candidate;
40
+ return `${candidate}_${Date.now()}`;
41
+ }
42
+
43
+ async slugPath(runId, scheme, content) {
44
+ const base = slugify(content || "");
45
+ const prefix = `${scheme}://`;
46
+
47
+ if (!base) return `${prefix}${Date.now()}`;
48
+
49
+ const candidate = `${prefix}${base}`;
50
+ const existing = await this.#db.get_entry_body.get({
51
+ run_id: runId,
52
+ path: candidate,
53
+ });
54
+ if (!existing) return candidate;
55
+
56
+ return `${prefix}${base}_${Date.now()}`;
57
+ }
58
+
59
+ async upsert(
60
+ runId,
61
+ turn,
62
+ path,
63
+ body,
64
+ state,
65
+ { attributes = null, hash = null, updatedAt = null } = {},
66
+ ) {
67
+ await this.#db.upsert_known_entry.run({
68
+ run_id: runId,
69
+ turn,
70
+ path: KnownStore.normalizePath(path),
71
+ body,
72
+ state,
73
+ hash,
74
+ attributes: attributes ? JSON.stringify(attributes) : null,
75
+ updated_at: updatedAt,
76
+ });
77
+ }
78
+
79
+ async promote(runId, path, turn) {
80
+ await this.#db.promote_path.run({
81
+ run_id: runId,
82
+ path: KnownStore.normalizePath(path),
83
+ turn,
84
+ });
85
+ }
86
+
87
+ async setFileState(runId, pattern, state) {
88
+ const result = await this.#db.set_file_state.run({
89
+ run_id: runId,
90
+ pattern,
91
+ state,
92
+ });
93
+ if (result.changes === 0) {
94
+ await this.upsert(runId, 0, pattern, "", state);
95
+ }
96
+ }
97
+
98
+ async demote(runId, path) {
99
+ await this.#db.demote_path.run({
100
+ run_id: runId,
101
+ path: KnownStore.normalizePath(path),
102
+ });
103
+ }
104
+
105
+ async remove(runId, path) {
106
+ await this.#db.delete_known_entry.run({
107
+ run_id: runId,
108
+ path: KnownStore.normalizePath(path),
109
+ });
110
+ }
111
+
112
+ async removeFilesByPattern(runId, pattern) {
113
+ await this.#db.delete_file_entries_by_pattern.run({
114
+ run_id: runId,
115
+ pattern,
116
+ });
117
+ }
118
+
119
+ static #bodyPattern(body) {
120
+ return body || null;
121
+ }
122
+
123
+ async promoteByPattern(runId, path, body, turn) {
124
+ await this.#db.promote_by_pattern.run({
125
+ run_id: runId,
126
+ path,
127
+ body: KnownStore.#bodyPattern(body),
128
+ turn,
129
+ });
130
+ }
131
+
132
+ async demoteByPattern(runId, path, body) {
133
+ await this.#db.demote_by_pattern.run({
134
+ run_id: runId,
135
+ path,
136
+ body: KnownStore.#bodyPattern(body),
137
+ });
138
+ }
139
+
140
+ async getEntriesByPattern(runId, path, body, { limit, offset } = {}) {
141
+ return this.#db.get_entries_by_pattern.all({
142
+ run_id: runId,
143
+ path,
144
+ body: KnownStore.#bodyPattern(body),
145
+ limit: limit ?? null,
146
+ offset: offset ?? null,
147
+ });
148
+ }
149
+
150
+ async deleteByPattern(runId, path, body) {
151
+ await this.#db.delete_entries_by_pattern.run({
152
+ run_id: runId,
153
+ path,
154
+ body: KnownStore.#bodyPattern(body),
155
+ });
156
+ }
157
+
158
+ async updateBodyByPattern(runId, path, body, newBody) {
159
+ await this.#db.update_body_by_pattern.run({
160
+ run_id: runId,
161
+ path,
162
+ body: KnownStore.#bodyPattern(body),
163
+ new_body: newBody,
164
+ });
165
+ }
166
+
167
+ async resolve(runId, path, state, body) {
168
+ await this.#db.resolve_known_entry.run({
169
+ run_id: runId,
170
+ path: KnownStore.normalizePath(path),
171
+ state,
172
+ body,
173
+ });
174
+ }
175
+
176
+ async getLog(runId) {
177
+ return this.#db.get_results.all({ run_id: runId });
178
+ }
179
+
180
+ async getFileEntries(runId) {
181
+ return this.#db.get_file_entries.all({ run_id: runId });
182
+ }
183
+
184
+ async getFileStatesByPattern(runId, pattern) {
185
+ return this.#db.get_file_states_by_pattern.all({ run_id: runId, pattern });
186
+ }
187
+
188
+ async hasRejections(runId) {
189
+ const row = await this.#db.has_rejections.get({ run_id: runId });
190
+ return row.count > 0;
191
+ }
192
+
193
+ async hasAcceptedActions(runId) {
194
+ const row = await this.#db.has_accepted_actions.get({ run_id: runId });
195
+ return row.count > 0;
196
+ }
197
+
198
+ async getUnresolved(runId) {
199
+ return this.#db.get_unresolved.all({ run_id: runId });
200
+ }
201
+
202
+ async countUnknowns(runId) {
203
+ const row = await this.#db.count_unknowns.get({ run_id: runId });
204
+ return row.count;
205
+ }
206
+
207
+ async getUnknownValues(runId) {
208
+ const rows = await this.#db.get_unknown_values.all({ run_id: runId });
209
+ return new Set(rows.map((r) => r.body));
210
+ }
211
+
212
+ async getBody(runId, path) {
213
+ const row = await this.#db.get_entry_body.get({
214
+ run_id: runId,
215
+ path: KnownStore.normalizePath(path),
216
+ });
217
+ return row?.body ?? null;
218
+ }
219
+
220
+ async setAttributes(runId, path, attrs) {
221
+ await this.#db.update_entry_attributes.run({
222
+ run_id: runId,
223
+ path: KnownStore.normalizePath(path),
224
+ attributes: JSON.stringify(attrs),
225
+ });
226
+ }
227
+
228
+ async getState(runId, path) {
229
+ return this.#db.get_entry_state.get({
230
+ run_id: runId,
231
+ path: KnownStore.normalizePath(path),
232
+ });
233
+ }
234
+
235
+ async getAttributes(runId, path) {
236
+ const row = await this.#db.get_entry_attributes.get({
237
+ run_id: runId,
238
+ path: KnownStore.normalizePath(path),
239
+ });
240
+ return row?.attributes ? JSON.parse(row.attributes) : null;
241
+ }
242
+
243
+ async getTurnAudit(runId, turn) {
244
+ return this.#db.get_turn_audit.all({ run_id: runId, turn });
245
+ }
246
+
247
+ static toolFromPath(path) {
248
+ return KnownStore.scheme(path);
249
+ }
250
+
251
+ static isSystemPath(path) {
252
+ return path.includes("://");
253
+ }
254
+ }
@@ -0,0 +1,101 @@
1
+ import LlmProvider from "../llm/LlmProvider.js";
2
+ import AgentLoop from "./AgentLoop.js";
3
+ import KnownStore from "./KnownStore.js";
4
+ import TurnExecutor from "./TurnExecutor.js";
5
+
6
+ export default class ProjectAgent {
7
+ #db;
8
+ #hooks;
9
+ #agentLoop;
10
+ #knownStore;
11
+ #llm;
12
+
13
+ constructor(db, hooks) {
14
+ this.#db = db;
15
+ this.#hooks = hooks;
16
+ this.#llm = new LlmProvider(db);
17
+ this.#knownStore = new KnownStore(db);
18
+
19
+ const turnExecutor = new TurnExecutor(
20
+ db,
21
+ this.#llm,
22
+ hooks,
23
+ this.#knownStore,
24
+ );
25
+ this.#agentLoop = new AgentLoop(
26
+ db,
27
+ this.#llm,
28
+ hooks,
29
+ turnExecutor,
30
+ this.#knownStore,
31
+ );
32
+ }
33
+
34
+ async init(projectName, projectRoot, configPath) {
35
+ await this.#hooks.project.init.started.emit({
36
+ projectName,
37
+ projectRoot,
38
+ });
39
+
40
+ const projectRow = await this.#db.upsert_project.get({
41
+ name: projectName,
42
+ project_root: projectRoot,
43
+ config_path: configPath || null,
44
+ });
45
+ const projectId = projectRow.id;
46
+
47
+ await this.#hooks.project.init.completed.emit({
48
+ projectId,
49
+ projectRoot,
50
+ db: this.#db,
51
+ });
52
+
53
+ return { projectId };
54
+ }
55
+
56
+ get entries() {
57
+ return this.#knownStore;
58
+ }
59
+
60
+ // --- Run operations ---
61
+
62
+ async ask(projectId, model, prompt, run = null, options = {}) {
63
+ return this.#agentLoop.run(
64
+ "ask",
65
+ projectId,
66
+ model,
67
+ prompt,
68
+ null,
69
+ run,
70
+ options,
71
+ );
72
+ }
73
+
74
+ async act(projectId, model, prompt, run = null, options = {}) {
75
+ return this.#agentLoop.run(
76
+ "act",
77
+ projectId,
78
+ model,
79
+ prompt,
80
+ null,
81
+ run,
82
+ options,
83
+ );
84
+ }
85
+
86
+ async resolve(run, resolution) {
87
+ return this.#agentLoop.resolve(run, resolution);
88
+ }
89
+
90
+ async inject(run, message) {
91
+ return this.#agentLoop.inject(run, message);
92
+ }
93
+
94
+ async getRunHistory(run) {
95
+ return this.#agentLoop.getRunHistory(run);
96
+ }
97
+
98
+ abortRun(runId) {
99
+ this.#agentLoop.abort(runId);
100
+ }
101
+ }
@@ -0,0 +1,134 @@
1
+ const MAX_STALLS = Number(process.env.RUMMY_MAX_STALLS) || 3;
2
+ const MAX_REPETITIONS = Number(process.env.RUMMY_MAX_REPETITIONS) || 3;
3
+
4
+ export default class ResponseHealer {
5
+ #stallCount = 0;
6
+ #lastFingerprint = null;
7
+ #repetitionCount = 0;
8
+
9
+ /**
10
+ * Heal a missing status tag. Called when the model emits
11
+ * neither <summarize/> nor <update/>.
12
+ */
13
+ /**
14
+ * Heal a missing status tag. Called when the model emits
15
+ * neither <summarize/> nor <update/>.
16
+ *
17
+ * Plain text with no commands = the model answered. Treat as summary.
18
+ * Commands with no status tag = the model is working. Treat as update.
19
+ */
20
+ static healStatus(content, commands) {
21
+ const trimmed = content.trim();
22
+
23
+ // No commands + plain text = answered. Treat as summary.
24
+ if (commands.length === 0 && trimmed) {
25
+ console.warn("[RUMMY] Healed: plain text response treated as summary");
26
+ return { summaryText: trimmed.slice(0, 500), updateText: null };
27
+ }
28
+
29
+ // Only write/unknown commands + no investigation tools = completed action.
30
+ // The model did the thing without saying <summarize>. Treat as summary.
31
+ const hasInvestigation = commands.some((c) =>
32
+ ["get", "env", "search", "ask_user"].includes(c.name),
33
+ );
34
+ if (!hasInvestigation && commands.length > 0) {
35
+ const names = commands.map((c) => c.name).join(", ");
36
+ console.warn(
37
+ `[RUMMY] Healed: action-only response (${names}) treated as summary`,
38
+ );
39
+ return {
40
+ summaryText: trimmed.slice(0, 500) || "Done.",
41
+ updateText: null,
42
+ };
43
+ }
44
+
45
+ console.warn(
46
+ `[RUMMY] Healed: missing <update>/<summarize>. Tools: ${commands.map((c) => c.name).join(", ") || "none"}`,
47
+ );
48
+ return { summaryText: null, updateText: "..." };
49
+ }
50
+
51
+ /**
52
+ * Check for repeated tool commands across turns.
53
+ * Returns { continue: boolean, reason?: string }
54
+ *
55
+ * Fingerprints the commands (name + path/query). If the same fingerprint
56
+ * repeats for MAX_REPETITIONS consecutive turns, force-complete.
57
+ */
58
+ assessRepetition({ actionCalls, writeCalls }) {
59
+ const commands = [...(actionCalls || []), ...(writeCalls || [])];
60
+ if (commands.length === 0) {
61
+ this.#lastFingerprint = null;
62
+ this.#repetitionCount = 0;
63
+ return { continue: true };
64
+ }
65
+
66
+ const fingerprint = commands
67
+ .map((c) => `${c.name}:${c.path || c.command || c.question || ""}`)
68
+ .toSorted()
69
+ .join("|");
70
+
71
+ if (fingerprint === this.#lastFingerprint) {
72
+ this.#repetitionCount++;
73
+ if (this.#repetitionCount >= MAX_REPETITIONS) {
74
+ const reason = `Same commands repeated ${this.#repetitionCount} turns`;
75
+ console.warn(`[RUMMY] Loop detected: ${reason}. Force-completing.`);
76
+ return { continue: false, reason };
77
+ }
78
+ console.warn(
79
+ `[RUMMY] Repeated commands (${this.#repetitionCount}/${MAX_REPETITIONS}): ${fingerprint.slice(0, 80)}`,
80
+ );
81
+ } else {
82
+ this.#repetitionCount = 1;
83
+ this.#lastFingerprint = fingerprint;
84
+ }
85
+
86
+ return { continue: true };
87
+ }
88
+
89
+ /**
90
+ * Assess whether the run should continue.
91
+ *
92
+ * Returns { continue: boolean, reason?: string }
93
+ *
94
+ * Rules:
95
+ * <summarize/> present → done (terminate)
96
+ * <update/> present → continue (model says it's working)
97
+ * neither present → warn, increment stall counter, continue
98
+ * stall counter hits MAX_STALLS → force-complete
99
+ */
100
+ assessProgress({ summaryText, updateText, statusHealed }) {
101
+ if (summaryText) {
102
+ this.#stallCount = 0;
103
+ return { continue: false };
104
+ }
105
+
106
+ if (updateText && !statusHealed) {
107
+ this.#stallCount = 0;
108
+ return { continue: true };
109
+ }
110
+
111
+ // Healed or neither — model is glitching
112
+ this.#stallCount++;
113
+
114
+ if (this.#stallCount >= MAX_STALLS) {
115
+ const reason = `${this.#stallCount} turns with no <update/> or <summarize/>`;
116
+ console.warn(`[RUMMY] Stalled: ${reason}. Force-completing.`);
117
+ return { continue: false, reason };
118
+ }
119
+
120
+ console.warn(
121
+ `[RUMMY] No <update/> or <summarize/> (stall ${this.#stallCount}/${MAX_STALLS})`,
122
+ );
123
+ return { continue: true };
124
+ }
125
+
126
+ /**
127
+ * Reset state for a new run or after resolution resume.
128
+ */
129
+ reset() {
130
+ this.#stallCount = 0;
131
+ this.#lastFingerprint = null;
132
+ this.#repetitionCount = 0;
133
+ }
134
+ }