@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.
- package/.env.example +55 -0
- package/LICENSE +21 -0
- package/PLUGINS.md +302 -0
- package/README.md +41 -0
- package/SPEC.md +524 -0
- package/lang/en.json +34 -0
- package/migrations/001_initial_schema.sql +226 -0
- package/package.json +54 -0
- package/service.js +143 -0
- package/src/agent/AgentLoop.js +553 -0
- package/src/agent/ContextAssembler.js +29 -0
- package/src/agent/KnownStore.js +254 -0
- package/src/agent/ProjectAgent.js +101 -0
- package/src/agent/ResponseHealer.js +134 -0
- package/src/agent/TurnExecutor.js +457 -0
- package/src/agent/XmlParser.js +247 -0
- package/src/agent/known_checks.sql +42 -0
- package/src/agent/known_queries.sql +80 -0
- package/src/agent/known_store.sql +161 -0
- package/src/agent/messages.js +17 -0
- package/src/agent/prompt_queue.sql +39 -0
- package/src/agent/runs.sql +114 -0
- package/src/agent/schemes.sql +3 -0
- package/src/agent/sessions.sql +51 -0
- package/src/agent/tokens.js +28 -0
- package/src/agent/turns.sql +36 -0
- package/src/hooks/HookRegistry.js +72 -0
- package/src/hooks/Hooks.js +115 -0
- package/src/hooks/PluginContext.js +116 -0
- package/src/hooks/RummyContext.js +181 -0
- package/src/hooks/ToolRegistry.js +83 -0
- package/src/llm/LlmProvider.js +107 -0
- package/src/llm/OllamaClient.js +88 -0
- package/src/llm/OpenAiClient.js +80 -0
- package/src/llm/OpenRouterClient.js +78 -0
- package/src/llm/XaiClient.js +113 -0
- package/src/plugins/ask_user/README.md +18 -0
- package/src/plugins/ask_user/ask_user.js +48 -0
- package/src/plugins/ask_user/docs.md +2 -0
- package/src/plugins/cp/README.md +18 -0
- package/src/plugins/cp/cp.js +55 -0
- package/src/plugins/cp/docs.md +2 -0
- package/src/plugins/current/README.md +14 -0
- package/src/plugins/current/current.js +48 -0
- package/src/plugins/engine/README.md +12 -0
- package/src/plugins/engine/engine.sql +18 -0
- package/src/plugins/engine/turn_context.sql +51 -0
- package/src/plugins/env/README.md +14 -0
- package/src/plugins/env/docs.md +2 -0
- package/src/plugins/env/env.js +32 -0
- package/src/plugins/file/README.md +25 -0
- package/src/plugins/file/file.js +85 -0
- package/src/plugins/get/README.md +19 -0
- package/src/plugins/get/docs.md +6 -0
- package/src/plugins/get/get.js +53 -0
- package/src/plugins/hedberg/README.md +72 -0
- package/src/plugins/hedberg/docs.md +9 -0
- package/src/plugins/hedberg/edits.js +65 -0
- package/src/plugins/hedberg/hedberg.js +89 -0
- package/src/plugins/hedberg/matcher.js +181 -0
- package/src/plugins/hedberg/normalize.js +41 -0
- package/src/plugins/hedberg/patterns.js +452 -0
- package/src/plugins/hedberg/sed.js +48 -0
- package/src/plugins/helpers.js +22 -0
- package/src/plugins/index.js +180 -0
- package/src/plugins/instructions/README.md +11 -0
- package/src/plugins/instructions/instructions.js +37 -0
- package/src/plugins/instructions/preamble.md +12 -0
- package/src/plugins/known/README.md +18 -0
- package/src/plugins/known/docs.md +3 -0
- package/src/plugins/known/known.js +57 -0
- package/src/plugins/mv/README.md +18 -0
- package/src/plugins/mv/docs.md +2 -0
- package/src/plugins/mv/mv.js +56 -0
- package/src/plugins/previous/README.md +15 -0
- package/src/plugins/previous/previous.js +50 -0
- package/src/plugins/progress/README.md +17 -0
- package/src/plugins/progress/progress.js +44 -0
- package/src/plugins/prompt/README.md +16 -0
- package/src/plugins/prompt/prompt.js +45 -0
- package/src/plugins/rm/README.md +18 -0
- package/src/plugins/rm/docs.md +4 -0
- package/src/plugins/rm/rm.js +51 -0
- package/src/plugins/rpc/README.md +45 -0
- package/src/plugins/rpc/rpc.js +587 -0
- package/src/plugins/set/README.md +32 -0
- package/src/plugins/set/docs.md +4 -0
- package/src/plugins/set/set.js +268 -0
- package/src/plugins/sh/README.md +18 -0
- package/src/plugins/sh/docs.md +2 -0
- package/src/plugins/sh/sh.js +32 -0
- package/src/plugins/skills/README.md +25 -0
- package/src/plugins/skills/skills.js +175 -0
- package/src/plugins/store/README.md +20 -0
- package/src/plugins/store/docs.md +5 -0
- package/src/plugins/store/store.js +52 -0
- package/src/plugins/summarize/README.md +18 -0
- package/src/plugins/summarize/docs.md +4 -0
- package/src/plugins/summarize/summarize.js +24 -0
- package/src/plugins/telemetry/README.md +19 -0
- package/src/plugins/telemetry/rpc_log.sql +28 -0
- package/src/plugins/telemetry/telemetry.js +186 -0
- package/src/plugins/unknown/README.md +23 -0
- package/src/plugins/unknown/docs.md +5 -0
- package/src/plugins/unknown/unknown.js +31 -0
- package/src/plugins/update/README.md +18 -0
- package/src/plugins/update/docs.md +4 -0
- package/src/plugins/update/update.js +24 -0
- package/src/server/ClientConnection.js +228 -0
- package/src/server/RpcRegistry.js +52 -0
- package/src/server/SocketServer.js +43 -0
- package/src/sql/file_constraints.sql +15 -0
- package/src/sql/functions/countTokens.js +7 -0
- package/src/sql/functions/hedmatch.js +8 -0
- package/src/sql/functions/hedreplace.js +8 -0
- package/src/sql/functions/hedsearch.js +8 -0
- package/src/sql/functions/schemeOf.js +7 -0
- package/src/sql/functions/slugify.js +6 -0
- package/src/sql/v_model_context.sql +101 -0
- package/src/sql/v_run_log.sql +23 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- PREP: log_rpc_call
|
|
2
|
+
INSERT INTO rpc_log (project_id, method, rpc_id, params)
|
|
3
|
+
VALUES (:project_id, :method, :rpc_id, :params)
|
|
4
|
+
RETURNING id;
|
|
5
|
+
|
|
6
|
+
-- PREP: log_rpc_result
|
|
7
|
+
UPDATE rpc_log
|
|
8
|
+
SET result = :result
|
|
9
|
+
WHERE id = :id;
|
|
10
|
+
|
|
11
|
+
-- PREP: log_rpc_error
|
|
12
|
+
UPDATE rpc_log
|
|
13
|
+
SET error = :error
|
|
14
|
+
WHERE id = :id;
|
|
15
|
+
|
|
16
|
+
-- PREP: get_rpc_log
|
|
17
|
+
SELECT id, project_id, method, rpc_id, params, result, error, created_at
|
|
18
|
+
FROM rpc_log
|
|
19
|
+
WHERE project_id = :project_id
|
|
20
|
+
ORDER BY id DESC
|
|
21
|
+
LIMIT :limit;
|
|
22
|
+
|
|
23
|
+
-- PREP: get_rpc_log_by_method
|
|
24
|
+
SELECT id, project_id, method, rpc_id, params, result, error, created_at
|
|
25
|
+
FROM rpc_log
|
|
26
|
+
WHERE project_id = :project_id AND method = :method
|
|
27
|
+
ORDER BY id DESC
|
|
28
|
+
LIMIT :limit;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export default class Telemetry {
|
|
5
|
+
#core;
|
|
6
|
+
#starts = new Map();
|
|
7
|
+
#lastRunPath = null;
|
|
8
|
+
#turnLog = [];
|
|
9
|
+
|
|
10
|
+
constructor(core) {
|
|
11
|
+
this.#core = core;
|
|
12
|
+
|
|
13
|
+
const home = process.env.RUMMY_HOME;
|
|
14
|
+
if (home) this.#lastRunPath = join(home, "last_run.txt");
|
|
15
|
+
|
|
16
|
+
core.on("rpc.started", this.#onRpcStarted.bind(this));
|
|
17
|
+
core.on("rpc.completed", this.#onRpcCompleted.bind(this));
|
|
18
|
+
core.on("rpc.error", this.#onRpcError.bind(this));
|
|
19
|
+
core.on("run.step.completed", this.#onStepCompleted.bind(this));
|
|
20
|
+
core.on("turn.response", this.#onTurnResponse.bind(this));
|
|
21
|
+
core.filter("llm.messages", this.#logMessages.bind(this), 999);
|
|
22
|
+
core.filter("llm.response", this.#logResponse.bind(this), 999);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async #onRpcStarted({ method, id, params }) {
|
|
26
|
+
this.#starts.set(id, Date.now());
|
|
27
|
+
const summary =
|
|
28
|
+
method === "ask" || method === "act"
|
|
29
|
+
? `prompt="${(params?.prompt || "").slice(0, 60)}"`
|
|
30
|
+
: method === "run/abort"
|
|
31
|
+
? `run=${params?.run}`
|
|
32
|
+
: method === "run/resolve"
|
|
33
|
+
? `run=${params?.run} action=${params?.resolution?.action}`
|
|
34
|
+
: "";
|
|
35
|
+
console.log(`[RPC] → ${method}(${id})${summary ? ` ${summary}` : ""}`);
|
|
36
|
+
|
|
37
|
+
if (method === "ask" || method === "act") {
|
|
38
|
+
this.#turnLog = [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async #onRpcCompleted({ method, id, result }) {
|
|
43
|
+
const elapsed = this.#starts.has(id)
|
|
44
|
+
? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
|
|
45
|
+
: "";
|
|
46
|
+
this.#starts.delete(id);
|
|
47
|
+
const summary = result?.run
|
|
48
|
+
? `run=${result.run} status=${result.status || "ok"}`
|
|
49
|
+
: result?.status
|
|
50
|
+
? `status=${result.status}`
|
|
51
|
+
: "";
|
|
52
|
+
console.log(
|
|
53
|
+
`[RPC] ← ${method}(${id}) ${elapsed}${summary ? ` ${summary}` : ""}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #onRpcError({ id, error }) {
|
|
58
|
+
const elapsed = this.#starts.has(id)
|
|
59
|
+
? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
|
|
60
|
+
: "";
|
|
61
|
+
this.#starts.delete(id);
|
|
62
|
+
console.error(`[RPC] ✗ (${id}) ${elapsed} ${error?.message || error}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #onStepCompleted(payload) {
|
|
66
|
+
if (process.env.RUMMY_DEBUG !== "true") return;
|
|
67
|
+
console.log(
|
|
68
|
+
`[DEBUG] Turn ${payload.turn} completed for run ${payload.run}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #onTurnResponse({
|
|
73
|
+
rummy,
|
|
74
|
+
turn,
|
|
75
|
+
result,
|
|
76
|
+
responseMessage,
|
|
77
|
+
content,
|
|
78
|
+
commands,
|
|
79
|
+
unparsed,
|
|
80
|
+
systemMsg,
|
|
81
|
+
userMsg,
|
|
82
|
+
}) {
|
|
83
|
+
const { entries: store, runId } = rummy;
|
|
84
|
+
|
|
85
|
+
// assistant://N — the model's raw response
|
|
86
|
+
await store.upsert(runId, turn, `assistant://${turn}`, content, "info");
|
|
87
|
+
|
|
88
|
+
// system://N, user://N — assembled messages as audit
|
|
89
|
+
if (systemMsg) {
|
|
90
|
+
await store.upsert(runId, turn, `system://${turn}`, systemMsg, "info");
|
|
91
|
+
}
|
|
92
|
+
if (userMsg) {
|
|
93
|
+
await store.upsert(runId, turn, `user://${turn}`, userMsg, "info");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// model://N — raw API response diagnostics
|
|
97
|
+
await store.upsert(
|
|
98
|
+
runId,
|
|
99
|
+
turn,
|
|
100
|
+
`model://${turn}`,
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
keys: responseMessage ? Object.keys(responseMessage) : [],
|
|
103
|
+
reasoning_content: responseMessage?.reasoning_content || null,
|
|
104
|
+
content: content.slice(0, 4096),
|
|
105
|
+
usage: result.usage || null,
|
|
106
|
+
model: result.model || null,
|
|
107
|
+
}),
|
|
108
|
+
"info",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// reasoning://N
|
|
112
|
+
if (responseMessage?.reasoning_content) {
|
|
113
|
+
await store.upsert(
|
|
114
|
+
runId,
|
|
115
|
+
turn,
|
|
116
|
+
`reasoning://${turn}`,
|
|
117
|
+
responseMessage.reasoning_content,
|
|
118
|
+
"info",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// content://N — unparsed text
|
|
123
|
+
if (unparsed) {
|
|
124
|
+
await store.upsert(runId, turn, `content://${turn}`, unparsed, "info");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Commit usage stats
|
|
128
|
+
const usage = result.usage || {};
|
|
129
|
+
const cachedTokens =
|
|
130
|
+
usage.cached_tokens ||
|
|
131
|
+
usage.prompt_tokens_details?.cached_tokens ||
|
|
132
|
+
usage.input_tokens_details?.cached_tokens ||
|
|
133
|
+
usage.cache_read_input_tokens ||
|
|
134
|
+
0;
|
|
135
|
+
const reasoningTokens =
|
|
136
|
+
usage.reasoning_tokens ||
|
|
137
|
+
usage.completion_tokens_details?.reasoning_tokens ||
|
|
138
|
+
usage.output_tokens_details?.reasoning_tokens ||
|
|
139
|
+
0;
|
|
140
|
+
await rummy.db.update_turn_stats.run({
|
|
141
|
+
id: rummy.turnId,
|
|
142
|
+
prompt_tokens: usage.prompt_tokens ?? 0,
|
|
143
|
+
cached_tokens: cachedTokens ?? 0,
|
|
144
|
+
completion_tokens: usage.completion_tokens ?? 0,
|
|
145
|
+
reasoning_tokens: reasoningTokens ?? 0,
|
|
146
|
+
total_tokens: usage.total_tokens ?? 0,
|
|
147
|
+
cost: usage.cost ?? 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async #logMessages(messages, context) {
|
|
152
|
+
this.#turnLog.push(
|
|
153
|
+
`\n${"=".repeat(60)}\nTURN — model=${context.model} run=${context.runId}\n${"=".repeat(60)}`,
|
|
154
|
+
);
|
|
155
|
+
for (const msg of messages) {
|
|
156
|
+
const label = msg.role.toUpperCase();
|
|
157
|
+
const body =
|
|
158
|
+
typeof msg.content === "string"
|
|
159
|
+
? msg.content
|
|
160
|
+
: JSON.stringify(msg.content);
|
|
161
|
+
this.#turnLog.push(`\n--- ${label} ---\n${body}`);
|
|
162
|
+
}
|
|
163
|
+
return messages;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #logResponse(response) {
|
|
167
|
+
const msg = response.choices?.[0]?.message;
|
|
168
|
+
this.#turnLog.push(`\n--- ASSISTANT ---\n${msg?.content || "(empty)"}`);
|
|
169
|
+
if (msg?.reasoning_content) {
|
|
170
|
+
this.#turnLog.push(`\n--- REASONING ---\n${msg.reasoning_content}`);
|
|
171
|
+
}
|
|
172
|
+
const usage = response.usage || {};
|
|
173
|
+
this.#turnLog.push(`\n--- USAGE ---\n${JSON.stringify(usage)}`);
|
|
174
|
+
this.#flush();
|
|
175
|
+
return response;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#flush() {
|
|
179
|
+
if (!this.#lastRunPath || this.#turnLog.length === 0) return;
|
|
180
|
+
try {
|
|
181
|
+
writeFileSync(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`);
|
|
182
|
+
} catch {
|
|
183
|
+
// RUMMY_HOME may not exist yet
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# unknown
|
|
2
|
+
|
|
3
|
+
The Rumsfeld mechanism. The model registers what it doesn't know before acting.
|
|
4
|
+
|
|
5
|
+
`<unknown>which database adapter is configured</unknown>`
|
|
6
|
+
|
|
7
|
+
## Registration
|
|
8
|
+
|
|
9
|
+
- **Tool**: `unknown`
|
|
10
|
+
- **Modes**: ask, act
|
|
11
|
+
- **Category**: structural
|
|
12
|
+
- **Handler**: None — recorded by TurnExecutor, deduplicated against existing unknowns.
|
|
13
|
+
|
|
14
|
+
## Projection
|
|
15
|
+
|
|
16
|
+
`# unknown\n{body}`
|
|
17
|
+
|
|
18
|
+
## Behavior
|
|
19
|
+
|
|
20
|
+
Unknowns are sticky — they persist across turns until the model explicitly
|
|
21
|
+
stores or removes them. The model investigates unknowns using `<get>`,
|
|
22
|
+
`<env>`, or `<ask_user>`, then removes resolved ones with `<rm>`.
|
|
23
|
+
Server deduplicates on insert.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## <unknown>[what you need to learn]</unknown> - Track open questions
|
|
2
|
+
Example: <unknown>contents of answer.txt</unknown>
|
|
3
|
+
Example: <unknown>which database adapter is configured</unknown>
|
|
4
|
+
* Use get, env, or ask_user to investigate unknowns
|
|
5
|
+
* When irrelevant or resolved, use <rm/> to remove from context.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export default class Unknown {
|
|
4
|
+
#core;
|
|
5
|
+
|
|
6
|
+
constructor(core) {
|
|
7
|
+
this.#core = core;
|
|
8
|
+
core.registerScheme({
|
|
9
|
+
validStates: ["full", "stored"],
|
|
10
|
+
category: "knowledge",
|
|
11
|
+
});
|
|
12
|
+
core.on("full", this.full.bind(this));
|
|
13
|
+
core.filter("assembly.system", this.assembleUnknowns.bind(this), 300);
|
|
14
|
+
const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
|
|
15
|
+
core.filter("instructions.toolDocs", async (content) =>
|
|
16
|
+
content ? `${content}\n\n${docs}` : docs,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
full(entry) {
|
|
21
|
+
return `# unknown\n${entry.body}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async assembleUnknowns(content, ctx) {
|
|
25
|
+
const entries = ctx.rows.filter((r) => r.category === "unknown");
|
|
26
|
+
if (entries.length === 0) return content;
|
|
27
|
+
|
|
28
|
+
const lines = entries.map((u) => `<unknown>${u.body}</unknown>`);
|
|
29
|
+
return `${content}\n\n<unknowns>\n${lines.join("\n")}\n</unknowns>`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# update
|
|
2
|
+
|
|
3
|
+
Structural tool for model-generated progress updates.
|
|
4
|
+
|
|
5
|
+
## Registration
|
|
6
|
+
|
|
7
|
+
- **Tool**: `update`
|
|
8
|
+
- **Modes**: ask, act
|
|
9
|
+
- **Category**: structural
|
|
10
|
+
- **Handler**: None — projection only.
|
|
11
|
+
|
|
12
|
+
## Projection
|
|
13
|
+
|
|
14
|
+
Shows `update` followed by the entry body.
|
|
15
|
+
|
|
16
|
+
## Behavior
|
|
17
|
+
|
|
18
|
+
No handler logic. Allows the model to emit progress/status entries that appear in context via projection.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export default class Update {
|
|
4
|
+
#core;
|
|
5
|
+
|
|
6
|
+
constructor(core) {
|
|
7
|
+
this.#core = core;
|
|
8
|
+
core.registerScheme({ validStates: ["info"], category: "structural" });
|
|
9
|
+
core.on("full", this.full.bind(this));
|
|
10
|
+
core.on("summary", this.summary.bind(this));
|
|
11
|
+
const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
|
|
12
|
+
core.filter("instructions.toolDocs", async (content) =>
|
|
13
|
+
content ? `${content}\n\n${docs}` : docs,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
full(entry) {
|
|
18
|
+
return `# update\n${entry.body}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
summary(entry) {
|
|
22
|
+
return this.full(entry);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import msg from "../agent/messages.js";
|
|
2
|
+
import ProjectAgent from "../agent/ProjectAgent.js";
|
|
3
|
+
|
|
4
|
+
export default class ClientConnection {
|
|
5
|
+
#ws;
|
|
6
|
+
#db;
|
|
7
|
+
#projectAgent;
|
|
8
|
+
#hooks;
|
|
9
|
+
#rpcRegistry;
|
|
10
|
+
#rpcLogPending = new Map();
|
|
11
|
+
#context = {
|
|
12
|
+
projectId: null,
|
|
13
|
+
projectRoot: null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
constructor(ws, db, hooks) {
|
|
17
|
+
this.#ws = ws;
|
|
18
|
+
this.#db = db;
|
|
19
|
+
this.#hooks = hooks;
|
|
20
|
+
this.#rpcRegistry = hooks.rpc.registry;
|
|
21
|
+
this.#projectAgent = new ProjectAgent(db, hooks);
|
|
22
|
+
|
|
23
|
+
this.#ws.on("message", (data) => this.#handleMessage(data));
|
|
24
|
+
|
|
25
|
+
this.#setupNotifications();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#setupNotifications() {
|
|
29
|
+
this.#hooks.run.progress.on((payload) => {
|
|
30
|
+
if (payload.projectId === this.#context.projectId) {
|
|
31
|
+
this.#sendNotification("run/progress", {
|
|
32
|
+
run: payload.run,
|
|
33
|
+
turn: payload.turn,
|
|
34
|
+
status: payload.status,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.#hooks.ui.render.on((payload) => {
|
|
40
|
+
if (payload.projectId === this.#context.projectId) {
|
|
41
|
+
this.#sendNotification("ui/render", {
|
|
42
|
+
text: payload.text,
|
|
43
|
+
append: payload.append,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.#hooks.ui.notify.on((payload) => {
|
|
49
|
+
if (payload.projectId === this.#context.projectId) {
|
|
50
|
+
this.#sendNotification("ui/notify", {
|
|
51
|
+
text: payload.text,
|
|
52
|
+
level: payload.level,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.#hooks.run.state.on((payload) => {
|
|
58
|
+
if (payload.projectId === this.#context.projectId) {
|
|
59
|
+
this.#sendNotification("run/state", {
|
|
60
|
+
run: payload.run,
|
|
61
|
+
turn: payload.turn,
|
|
62
|
+
status: payload.status,
|
|
63
|
+
summary: payload.summary,
|
|
64
|
+
history: payload.history,
|
|
65
|
+
unknowns: payload.unknowns,
|
|
66
|
+
proposed: payload.proposed,
|
|
67
|
+
telemetry: payload.telemetry,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#buildHandlerContext() {
|
|
74
|
+
return {
|
|
75
|
+
projectAgent: this.#projectAgent,
|
|
76
|
+
db: this.#db,
|
|
77
|
+
rpcRegistry: this.#rpcRegistry,
|
|
78
|
+
projectId: this.#context.projectId,
|
|
79
|
+
projectRoot: this.#context.projectRoot,
|
|
80
|
+
setContext: (projectId, projectRoot) => {
|
|
81
|
+
this.#context.projectId = projectId;
|
|
82
|
+
this.#context.projectRoot = projectRoot;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async handleMessageForTest(data) {
|
|
88
|
+
return this.#handleMessage(data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #handleMessage(data) {
|
|
92
|
+
let id = null;
|
|
93
|
+
const debug = process.env.RUMMY_DEBUG === "true";
|
|
94
|
+
try {
|
|
95
|
+
const rawMessage = await this.#hooks.socket.message.raw.filter(data);
|
|
96
|
+
if (debug) console.log(`[SOCKET] IN: ${rawMessage.toString()}`);
|
|
97
|
+
|
|
98
|
+
const message = JSON.parse(rawMessage.toString());
|
|
99
|
+
|
|
100
|
+
const filteredRequest = await this.#hooks.rpc.request.filter(message);
|
|
101
|
+
const { method, params, id: msgId } = filteredRequest;
|
|
102
|
+
id = msgId;
|
|
103
|
+
|
|
104
|
+
await this.#hooks.rpc.started.emit({
|
|
105
|
+
method,
|
|
106
|
+
params,
|
|
107
|
+
id,
|
|
108
|
+
projectId: this.#context.projectId,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const logRow = await this.#db.log_rpc_call.get({
|
|
113
|
+
project_id: this.#context.projectId || null,
|
|
114
|
+
method,
|
|
115
|
+
rpc_id: id,
|
|
116
|
+
params: params ? JSON.stringify(params) : null,
|
|
117
|
+
});
|
|
118
|
+
if (logRow) this.#rpcLogPending.set(id, logRow.id);
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
const resolvedMethod = method === "rpc/discover" ? "discover" : method;
|
|
122
|
+
const registration = this.#rpcRegistry.get(resolvedMethod);
|
|
123
|
+
if (!registration)
|
|
124
|
+
throw new Error(msg("error.method_not_found", { method }));
|
|
125
|
+
|
|
126
|
+
if (registration.requiresInit && !this.#context.projectId) {
|
|
127
|
+
throw new Error(msg("error.not_initialized"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let result;
|
|
131
|
+
if (registration.longRunning) {
|
|
132
|
+
result = await registration.handler(
|
|
133
|
+
params || {},
|
|
134
|
+
this.#buildHandlerContext(),
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
const timeout = Number(process.env.RUMMY_RPC_TIMEOUT) || 10_000;
|
|
138
|
+
result = await Promise.race([
|
|
139
|
+
registration.handler(params || {}, this.#buildHandlerContext()),
|
|
140
|
+
new Promise((_, reject) =>
|
|
141
|
+
setTimeout(
|
|
142
|
+
() =>
|
|
143
|
+
reject(
|
|
144
|
+
new Error(
|
|
145
|
+
msg("error.rpc_timeout", {
|
|
146
|
+
method: resolvedMethod,
|
|
147
|
+
timeout,
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
timeout,
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const finalResult = await this.#hooks.rpc.response.result.filter(result, {
|
|
158
|
+
method,
|
|
159
|
+
id,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
this.#send({
|
|
163
|
+
jsonrpc: "2.0",
|
|
164
|
+
result: finalResult,
|
|
165
|
+
id,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await this.#hooks.rpc.completed.emit({
|
|
169
|
+
method,
|
|
170
|
+
id,
|
|
171
|
+
result: finalResult,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const logId = this.#rpcLogPending.get(id);
|
|
175
|
+
if (logId) {
|
|
176
|
+
this.#rpcLogPending.delete(id);
|
|
177
|
+
try {
|
|
178
|
+
await this.#db.log_rpc_result.run({
|
|
179
|
+
id: logId,
|
|
180
|
+
result: finalResult
|
|
181
|
+
? JSON.stringify(finalResult).slice(0, 4096)
|
|
182
|
+
: null,
|
|
183
|
+
});
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (debug) {
|
|
188
|
+
console.error(`[SOCKET] ERR: ${error.message}`);
|
|
189
|
+
console.error(`[DEBUG] Stack: ${error.stack}`);
|
|
190
|
+
}
|
|
191
|
+
this.#send({
|
|
192
|
+
jsonrpc: "2.0",
|
|
193
|
+
error: { code: -32603, message: error.message },
|
|
194
|
+
id: id || null,
|
|
195
|
+
});
|
|
196
|
+
await this.#hooks.rpc.error.emit({ id, error });
|
|
197
|
+
|
|
198
|
+
const errLogId = this.#rpcLogPending.get(id);
|
|
199
|
+
if (errLogId) {
|
|
200
|
+
this.#rpcLogPending.delete(id);
|
|
201
|
+
try {
|
|
202
|
+
await this.#db.log_rpc_error.run({
|
|
203
|
+
id: errLogId,
|
|
204
|
+
error: error.message,
|
|
205
|
+
});
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#send(payload) {
|
|
212
|
+
const debug = process.env.RUMMY_DEBUG === "true";
|
|
213
|
+
if (debug) {
|
|
214
|
+
console.log(`[SOCKET] OUT: ${JSON.stringify(payload, null, 2)}`);
|
|
215
|
+
}
|
|
216
|
+
if (this.#ws.readyState === 1) {
|
|
217
|
+
this.#ws.send(JSON.stringify(payload));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#sendNotification(method, params) {
|
|
222
|
+
this.#send({
|
|
223
|
+
jsonrpc: "2.0",
|
|
224
|
+
method,
|
|
225
|
+
params,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export default class RpcRegistry {
|
|
2
|
+
#methods = new Map();
|
|
3
|
+
#notifications = new Map();
|
|
4
|
+
|
|
5
|
+
register(
|
|
6
|
+
name,
|
|
7
|
+
{
|
|
8
|
+
handler,
|
|
9
|
+
description = "",
|
|
10
|
+
params = {},
|
|
11
|
+
requiresInit = false,
|
|
12
|
+
longRunning = false,
|
|
13
|
+
},
|
|
14
|
+
) {
|
|
15
|
+
if (this.#methods.has(name))
|
|
16
|
+
throw new Error(`RPC method '${name}' already registered.`);
|
|
17
|
+
this.#methods.set(
|
|
18
|
+
name,
|
|
19
|
+
Object.freeze({
|
|
20
|
+
handler,
|
|
21
|
+
description,
|
|
22
|
+
params,
|
|
23
|
+
requiresInit,
|
|
24
|
+
longRunning,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
registerNotification(name, description = "") {
|
|
30
|
+
this.#notifications.set(name, Object.freeze({ description }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get(name) {
|
|
34
|
+
return this.#methods.get(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
has(name) {
|
|
38
|
+
return this.#methods.has(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
discover() {
|
|
42
|
+
const methods = {};
|
|
43
|
+
for (const [name, def] of this.#methods) {
|
|
44
|
+
methods[name] = { description: def.description, params: def.params };
|
|
45
|
+
}
|
|
46
|
+
const notifications = {};
|
|
47
|
+
for (const [name, def] of this.#notifications) {
|
|
48
|
+
notifications[name] = { description: def.description };
|
|
49
|
+
}
|
|
50
|
+
return { methods, notifications };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { WebSocketServer } from "ws";
|
|
2
|
+
import ClientConnection from "./ClientConnection.js";
|
|
3
|
+
|
|
4
|
+
export default class SocketServer {
|
|
5
|
+
#db;
|
|
6
|
+
#wss;
|
|
7
|
+
#hooks;
|
|
8
|
+
|
|
9
|
+
constructor(db, options) {
|
|
10
|
+
this.#db = db;
|
|
11
|
+
this.#hooks = options.hooks;
|
|
12
|
+
this.#wss = new WebSocketServer(options);
|
|
13
|
+
|
|
14
|
+
this.#wss.on("connection", (ws, req) => {
|
|
15
|
+
if (process.env.RUMMY_DEBUG === "true") {
|
|
16
|
+
console.log(`[SOCKET] New connection from ${req.socket.remoteAddress}`);
|
|
17
|
+
}
|
|
18
|
+
new ClientConnection(ws, this.#db, this.#hooks);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
this.#wss.on("error", (_err) => {
|
|
22
|
+
// Proxy to registry or handle locally
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
address() {
|
|
27
|
+
return this.#wss.address();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
on(event, handler) {
|
|
31
|
+
this.#wss.on(event, handler);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
close() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
if (!this.#wss) return resolve();
|
|
37
|
+
for (const client of this.#wss.clients) {
|
|
38
|
+
client.terminate();
|
|
39
|
+
}
|
|
40
|
+
this.#wss.close(resolve);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- PREP: upsert_file_constraint
|
|
2
|
+
INSERT INTO file_constraints (project_id, pattern, visibility)
|
|
3
|
+
VALUES (:project_id, :pattern, :visibility)
|
|
4
|
+
ON CONFLICT (project_id, pattern) DO UPDATE SET
|
|
5
|
+
visibility = excluded.visibility;
|
|
6
|
+
|
|
7
|
+
-- PREP: delete_file_constraint
|
|
8
|
+
DELETE FROM file_constraints
|
|
9
|
+
WHERE project_id = :project_id AND pattern = :pattern;
|
|
10
|
+
|
|
11
|
+
-- PREP: get_file_constraints
|
|
12
|
+
SELECT pattern, visibility
|
|
13
|
+
FROM file_constraints
|
|
14
|
+
WHERE project_id = :project_id
|
|
15
|
+
ORDER BY pattern;
|