@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.
- package/.env.example +21 -4
- package/PLUGINS.md +389 -194
- package/README.md +25 -8
- package/SPEC.md +850 -373
- package/bin/demo.js +166 -0
- package/bin/rummy.js +9 -3
- package/biome/no-fallbacks.grit +50 -0
- package/lang/en.json +2 -2
- package/migrations/001_initial_schema.sql +88 -37
- package/package.json +6 -4
- package/service.js +50 -9
- package/src/agent/AgentLoop.js +460 -331
- package/src/agent/ContextAssembler.js +4 -2
- package/src/agent/Entries.js +655 -0
- package/src/agent/ProjectAgent.js +30 -18
- package/src/agent/TurnExecutor.js +232 -379
- package/src/agent/XmlParser.js +242 -67
- package/src/agent/budget.js +56 -0
- package/src/agent/errors.js +22 -0
- package/src/agent/httpStatus.js +39 -0
- package/src/agent/known_checks.sql +8 -4
- package/src/agent/known_queries.sql +9 -13
- package/src/agent/known_store.sql +275 -118
- package/src/agent/materializeContext.js +102 -0
- package/src/agent/runs.sql +10 -7
- package/src/agent/schemes.sql +14 -3
- package/src/agent/turns.sql +9 -9
- package/src/hooks/HookRegistry.js +6 -5
- package/src/hooks/Hooks.js +44 -3
- package/src/hooks/PluginContext.js +35 -21
- package/src/{server → hooks}/RpcRegistry.js +2 -1
- package/src/hooks/RummyContext.js +140 -37
- package/src/hooks/ToolRegistry.js +36 -35
- package/src/llm/LlmProvider.js +64 -90
- package/src/llm/errors.js +21 -0
- package/src/plugins/ask_user/README.md +1 -1
- package/src/plugins/ask_user/ask_user.js +37 -12
- package/src/plugins/ask_user/ask_userDoc.js +2 -23
- package/src/plugins/ask_user/ask_userDoc.md +10 -0
- package/src/plugins/budget/README.md +27 -23
- package/src/plugins/budget/budget.js +261 -69
- package/src/plugins/cp/README.md +2 -2
- package/src/plugins/cp/cp.js +31 -13
- package/src/plugins/cp/cpDoc.js +2 -23
- package/src/plugins/cp/cpDoc.md +7 -0
- package/src/plugins/engine/README.md +2 -2
- package/src/plugins/engine/engine.sql +4 -4
- package/src/plugins/engine/turn_context.sql +10 -10
- package/src/plugins/env/README.md +20 -5
- package/src/plugins/env/env.js +47 -8
- package/src/plugins/env/envDoc.js +2 -23
- package/src/plugins/env/envDoc.md +13 -0
- package/src/plugins/error/README.md +16 -0
- package/src/plugins/error/error.js +151 -0
- package/src/plugins/file/README.md +6 -6
- package/src/plugins/file/file.js +15 -7
- package/src/plugins/get/README.md +1 -1
- package/src/plugins/get/get.js +125 -49
- package/src/plugins/get/getDoc.js +2 -43
- package/src/plugins/get/getDoc.md +36 -0
- package/src/plugins/hedberg/README.md +1 -2
- package/src/plugins/hedberg/hedberg.js +8 -4
- package/src/plugins/hedberg/matcher.js +16 -17
- package/src/plugins/hedberg/normalize.js +0 -48
- package/src/plugins/helpers.js +43 -3
- package/src/plugins/index.js +146 -123
- package/src/plugins/instructions/README.md +35 -9
- package/src/plugins/instructions/instructions.js +126 -12
- package/src/plugins/instructions/instructions.md +25 -0
- package/src/plugins/instructions/instructions_104.md +7 -0
- package/src/plugins/instructions/instructions_105.md +46 -0
- package/src/plugins/instructions/instructions_106.md +0 -0
- package/src/plugins/instructions/instructions_107.md +0 -0
- package/src/plugins/instructions/instructions_108.md +8 -0
- package/src/plugins/instructions/protocol.js +12 -0
- package/src/plugins/known/README.md +2 -2
- package/src/plugins/known/known.js +77 -45
- package/src/plugins/known/knownDoc.js +2 -29
- package/src/plugins/known/knownDoc.md +8 -0
- package/src/plugins/log/README.md +48 -0
- package/src/plugins/log/log.js +109 -0
- package/src/plugins/mv/README.md +2 -2
- package/src/plugins/mv/mv.js +57 -24
- package/src/plugins/mv/mvDoc.js +2 -29
- package/src/plugins/mv/mvDoc.md +10 -0
- package/src/plugins/ollama/README.md +15 -0
- package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
- package/src/plugins/openai/README.md +17 -0
- package/src/plugins/openai/openai.js +120 -0
- package/src/plugins/openrouter/README.md +27 -0
- package/src/plugins/openrouter/openrouter.js +121 -0
- package/src/plugins/persona/README.md +20 -0
- package/src/plugins/persona/persona.js +9 -16
- package/src/plugins/policy/README.md +21 -0
- package/src/plugins/policy/policy.js +29 -14
- package/src/plugins/prompt/README.md +1 -1
- package/src/plugins/prompt/prompt.js +63 -18
- package/src/plugins/rm/README.md +1 -1
- package/src/plugins/rm/rm.js +58 -14
- package/src/plugins/rm/rmDoc.js +2 -24
- package/src/plugins/rm/rmDoc.md +13 -0
- package/src/plugins/rpc/README.md +2 -2
- package/src/plugins/rpc/rpc.js +515 -296
- package/src/plugins/set/README.md +1 -1
- package/src/plugins/set/set.js +318 -77
- package/src/plugins/set/setDoc.js +2 -35
- package/src/plugins/set/setDoc.md +22 -0
- package/src/plugins/sh/README.md +28 -5
- package/src/plugins/sh/sh.js +52 -8
- package/src/plugins/sh/shDoc.js +2 -23
- package/src/plugins/sh/shDoc.md +13 -0
- package/src/plugins/skill/README.md +23 -0
- package/src/plugins/skill/skill.js +14 -17
- package/src/plugins/stream/README.md +101 -0
- package/src/plugins/stream/stream.js +290 -0
- package/src/plugins/telemetry/README.md +1 -1
- package/src/plugins/telemetry/telemetry.js +148 -74
- package/src/plugins/think/README.md +1 -1
- package/src/plugins/think/think.js +14 -1
- package/src/plugins/think/thinkDoc.js +2 -17
- package/src/plugins/think/thinkDoc.md +7 -0
- package/src/plugins/unknown/README.md +3 -3
- package/src/plugins/unknown/unknown.js +56 -21
- package/src/plugins/unknown/unknownDoc.js +2 -25
- package/src/plugins/unknown/unknownDoc.md +11 -0
- package/src/plugins/update/README.md +1 -1
- package/src/plugins/update/update.js +67 -5
- package/src/plugins/update/updateDoc.js +2 -27
- package/src/plugins/update/updateDoc.md +8 -0
- package/src/plugins/xai/README.md +23 -0
- package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
- package/src/server/ClientConnection.js +64 -37
- package/src/server/SocketServer.js +23 -10
- package/src/server/protocol.js +11 -0
- package/src/sql/functions/slugify.js +13 -1
- package/src/sql/v_model_context.sql +27 -31
- package/src/sql/v_run_log.sql +9 -14
- package/EXCEPTIONS.md +0 -46
- package/src/agent/KnownStore.js +0 -338
- package/src/agent/ResponseHealer.js +0 -188
- package/src/llm/OpenAiClient.js +0 -100
- package/src/llm/OpenRouterClient.js +0 -100
- package/src/plugins/budget/recovery.js +0 -47
- package/src/plugins/instructions/preamble.md +0 -37
- package/src/plugins/performed/README.md +0 -15
- package/src/plugins/performed/performed.js +0 -45
- package/src/plugins/previous/README.md +0 -16
- package/src/plugins/previous/previous.js +0 -60
- package/src/plugins/progress/README.md +0 -16
- package/src/plugins/progress/progress.js +0 -26
- package/src/plugins/summarize/README.md +0 -19
- package/src/plugins/summarize/summarize.js +0 -32
- package/src/plugins/summarize/summarizeDoc.js +0 -28
package/src/plugins/sh/sh.js
CHANGED
|
@@ -1,33 +1,77 @@
|
|
|
1
|
+
import { logPathToDataBase } from "../helpers.js";
|
|
1
2
|
import docs from "./shDoc.js";
|
|
2
3
|
|
|
4
|
+
const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
|
|
5
|
+
|
|
3
6
|
export default class Sh {
|
|
4
7
|
#core;
|
|
5
8
|
|
|
6
9
|
constructor(core) {
|
|
7
10
|
this.#core = core;
|
|
8
|
-
|
|
11
|
+
// `sh` scheme holds the streamed stdout/stderr payload — that's
|
|
12
|
+
// data the model reads, not an audit record. The log entry at
|
|
13
|
+
// log://turn_N/sh/{slug} (scheme=log, category=logging) is the
|
|
14
|
+
// audit record; it lives in a separate namespace by design.
|
|
15
|
+
// See SPEC §streaming_entries and the scheme/category invariant.
|
|
16
|
+
core.registerScheme({ category: "data" });
|
|
9
17
|
core.on("handler", this.handler.bind(this));
|
|
10
|
-
core.on("
|
|
11
|
-
core.on("
|
|
18
|
+
core.on("visible", this.full.bind(this));
|
|
19
|
+
core.on("summarized", this.summary.bind(this));
|
|
12
20
|
core.filter("instructions.toolDocs", async (docsMap) => {
|
|
13
21
|
docsMap.sh = docs;
|
|
14
22
|
return docsMap;
|
|
15
23
|
});
|
|
24
|
+
core.on("proposal.accepted", this.#onAccepted.bind(this));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async #onAccepted(ctx) {
|
|
28
|
+
const m = LOG_ACTION_RE.exec(ctx.path);
|
|
29
|
+
if (m?.[1] !== "sh") return;
|
|
30
|
+
let command = "";
|
|
31
|
+
if (ctx.attrs?.command) command = ctx.attrs.command;
|
|
32
|
+
else if (ctx.attrs?.summary) command = ctx.attrs.summary;
|
|
33
|
+
const turn = (await ctx.db.get_run_by_id.get({ id: ctx.runId })).next_turn;
|
|
34
|
+
const dataBase = logPathToDataBase(ctx.path);
|
|
35
|
+
for (const ch of [1, 2]) {
|
|
36
|
+
await ctx.entries.set({
|
|
37
|
+
runId: ctx.runId,
|
|
38
|
+
turn,
|
|
39
|
+
path: `${dataBase}_${ch}`,
|
|
40
|
+
body: "",
|
|
41
|
+
state: "streaming",
|
|
42
|
+
visibility: "summarized",
|
|
43
|
+
attributes: { command, summary: command, channel: ch },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await ctx.entries.set({
|
|
47
|
+
runId: ctx.runId,
|
|
48
|
+
path: ctx.path,
|
|
49
|
+
state: "resolved",
|
|
50
|
+
body: `ran '${command}' (in progress). Output: ${dataBase}_1, ${dataBase}_2`,
|
|
51
|
+
});
|
|
16
52
|
}
|
|
17
53
|
|
|
18
54
|
async handler(entry, rummy) {
|
|
19
55
|
const { entries: store, sequence: turn, runId, loopId } = rummy;
|
|
20
|
-
|
|
21
|
-
|
|
56
|
+
// Proposal at 202 with the command as summary and empty body — the
|
|
57
|
+
// body fills in on accept (log message about the action). Data
|
|
58
|
+
// entries with stdout/stderr are created on accept in resolve().
|
|
59
|
+
await store.set({
|
|
60
|
+
runId,
|
|
61
|
+
turn,
|
|
62
|
+
path: entry.resultPath,
|
|
63
|
+
body: "",
|
|
64
|
+
state: "proposed",
|
|
65
|
+
attributes: { ...entry.attributes, summary: entry.attributes.command },
|
|
22
66
|
loopId,
|
|
23
67
|
});
|
|
24
68
|
}
|
|
25
69
|
|
|
26
70
|
full(entry) {
|
|
27
|
-
return `# sh ${entry.attributes.command
|
|
71
|
+
return `# sh ${entry.attributes.command}\n${entry.body}`;
|
|
28
72
|
}
|
|
29
73
|
|
|
30
|
-
summary(
|
|
31
|
-
return
|
|
74
|
+
summary() {
|
|
75
|
+
return "";
|
|
32
76
|
}
|
|
33
77
|
}
|
package/src/plugins/sh/shDoc.js
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
// Text goes to the model. Rationale stays in source.
|
|
3
|
-
// Changing ANY line requires reading ALL rationales first.
|
|
4
|
-
const LINES = [
|
|
5
|
-
["## <sh>[command]</sh> - Run a shell command with side effects"],
|
|
6
|
-
[
|
|
7
|
-
"Example: <sh>npm install express</sh>",
|
|
8
|
-
"Package install. Real side-effect command.",
|
|
9
|
-
],
|
|
10
|
-
[
|
|
11
|
-
"Example: <sh>npm test</sh>",
|
|
12
|
-
"Test execution. Another common side-effect action.",
|
|
13
|
-
],
|
|
14
|
-
[
|
|
15
|
-
"* YOU MUST NOT use <sh/> to read, create, or edit files — use <get/> and <set/>",
|
|
16
|
-
"Forces file operations through the entry system.",
|
|
17
|
-
],
|
|
18
|
-
[
|
|
19
|
-
"* YOU MUST use <env/> for commands without side effects",
|
|
20
|
-
"Reinforces the env/sh split. Read = env, mutate = sh.",
|
|
21
|
-
],
|
|
22
|
-
];
|
|
1
|
+
import { loadDoc } from "../helpers.js";
|
|
23
2
|
|
|
24
|
-
export default
|
|
3
|
+
export default loadDoc(import.meta.url, "shDoc.md");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## <sh>[command]</sh> - Run a shell command with side effects
|
|
2
|
+
|
|
3
|
+
Example: <sh>npm install express</sh>
|
|
4
|
+
<!-- Package install. Real side-effect command. -->
|
|
5
|
+
|
|
6
|
+
Example: <sh>npm test</sh>
|
|
7
|
+
<!-- Test execution. Another common side-effect action. -->
|
|
8
|
+
|
|
9
|
+
* YOU MUST NOT use <sh></sh> to read, create, or edit files — use <get></get> and <set></set>
|
|
10
|
+
<!-- Forces file operations through the entry system. -->
|
|
11
|
+
|
|
12
|
+
* YOU MUST use <env></env> for commands without side effects
|
|
13
|
+
<!-- Reinforces the env/sh split. Read = env, mutate = sh. -->
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# skill {#skill_plugin}
|
|
2
|
+
|
|
3
|
+
Runtime skill management. A skill is a markdown file that gets
|
|
4
|
+
attached to a run as a `skill://<name>` entry. Models see skills
|
|
5
|
+
like any other entry.
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
- **skill.js** — RPC registration and skill file loading.
|
|
10
|
+
|
|
11
|
+
## Registration
|
|
12
|
+
|
|
13
|
+
- **Scheme**: `skill` (category: `data`)
|
|
14
|
+
- **Projections**: visible → body; summarized → empty.
|
|
15
|
+
|
|
16
|
+
## RPC Methods
|
|
17
|
+
|
|
18
|
+
| Method | Params | Notes |
|
|
19
|
+
|--------|--------|-------|
|
|
20
|
+
| `skill/add` | `{ run, name }` | Load `${RUMMY_HOME}/skills/<name>.md` and attach it to the run as `skill://<name>`. |
|
|
21
|
+
| `skill/remove` | `{ run, name }` | Remove the skill entry from the run. |
|
|
22
|
+
| `getSkills` | `{ run }` | Skills active on a run. |
|
|
23
|
+
| `listSkills` | — | Available skill files on disk. |
|
|
@@ -10,7 +10,8 @@ export default class Skill {
|
|
|
10
10
|
name: "skill",
|
|
11
11
|
category: "data",
|
|
12
12
|
});
|
|
13
|
-
core.hooks.tools.onView("skill", (entry) => entry.body);
|
|
13
|
+
core.hooks.tools.onView("skill", (entry) => entry.body, "visible");
|
|
14
|
+
core.hooks.tools.onView("skill", () => "", "summarized");
|
|
14
15
|
|
|
15
16
|
const r = core.hooks.rpc.registry;
|
|
16
17
|
|
|
@@ -24,7 +25,12 @@ export default class Skill {
|
|
|
24
25
|
|
|
25
26
|
const body = await loadFile("skills", params.name);
|
|
26
27
|
const store = ctx.projectAgent.entries;
|
|
27
|
-
await store.
|
|
28
|
+
await store.set({
|
|
29
|
+
runId: runRow.id,
|
|
30
|
+
turn: 0,
|
|
31
|
+
path: `skill://${params.name}`,
|
|
32
|
+
body,
|
|
33
|
+
state: "resolved",
|
|
28
34
|
attributes: {
|
|
29
35
|
name: params.name,
|
|
30
36
|
source: filePath("skills", params.name),
|
|
@@ -51,7 +57,7 @@ export default class Skill {
|
|
|
51
57
|
if (!runRow) throw new Error(`Run not found: ${params.run}`);
|
|
52
58
|
|
|
53
59
|
const store = ctx.projectAgent.entries;
|
|
54
|
-
await store.
|
|
60
|
+
await store.rm({ runId: runRow.id, path: `skill://${params.name}` });
|
|
55
61
|
|
|
56
62
|
return { status: "ok" };
|
|
57
63
|
},
|
|
@@ -111,23 +117,14 @@ function filePath(subfolder, name) {
|
|
|
111
117
|
async function loadFile(subfolder, name) {
|
|
112
118
|
const path = filePath(subfolder, name);
|
|
113
119
|
if (!path) throw new Error("RUMMY_HOME not configured");
|
|
114
|
-
|
|
115
|
-
return await fs.readFile(path, "utf8");
|
|
116
|
-
} catch (err) {
|
|
117
|
-
if (err.code === "ENOENT") throw new Error(`Not found: ${path}`);
|
|
118
|
-
throw err;
|
|
119
|
-
}
|
|
120
|
+
return fs.readFile(path, "utf8");
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
async function listAvailable(subfolder) {
|
|
123
124
|
const dir = configDir(subfolder);
|
|
124
125
|
if (!dir) return [];
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
|
|
130
|
-
} catch {
|
|
131
|
-
return [];
|
|
132
|
-
}
|
|
126
|
+
const files = await fs.readdir(dir);
|
|
127
|
+
return files
|
|
128
|
+
.filter((f) => f.endsWith(".md"))
|
|
129
|
+
.map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
|
|
133
130
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# stream {#stream_plugin}
|
|
2
|
+
|
|
3
|
+
Generic streaming entry infrastructure. Provides RPC methods that any
|
|
4
|
+
producer plugin (sh, env, future: search, fetch, watch) can use to
|
|
5
|
+
populate data entries over time.
|
|
6
|
+
|
|
7
|
+
## Namespace split
|
|
8
|
+
|
|
9
|
+
A streaming action lives in **two namespaces** by design:
|
|
10
|
+
|
|
11
|
+
- **Log entry** (audit record): `log://turn_N/{action}/{slug}` —
|
|
12
|
+
scheme=`log`, category=`logging`. Created by the producer's dispatch
|
|
13
|
+
handler (via `TurnExecutor` → `logPath`). This is the proposal the
|
|
14
|
+
client resolves. Renders inside `<log>`.
|
|
15
|
+
- **Data channels** (payload): `{action}://turn_N/{slug}_1`,
|
|
16
|
+
`{action}://turn_N/{slug}_2`, ... — scheme=`{action}` (sh, env, ...),
|
|
17
|
+
category=`data`. Created at status=102 on proposal acceptance. Grow
|
|
18
|
+
via `stream`; terminal via `stream/completed` / `stream/aborted` /
|
|
19
|
+
`stream/cancel`. Render inside `<context>`.
|
|
20
|
+
|
|
21
|
+
The stream RPC `path` param is always the **log-entry path** (that's
|
|
22
|
+
what clients receive on `run/proposal`). The server derives the data
|
|
23
|
+
base path internally via `logPathToDataBase`. See
|
|
24
|
+
[scheme_category_split](#scheme_category_split).
|
|
25
|
+
|
|
26
|
+
## RPC Methods
|
|
27
|
+
|
|
28
|
+
### `stream { run, path, channel, chunk }`
|
|
29
|
+
|
|
30
|
+
Append `chunk` to the data channel entry at `{dataBase}_{channel}`,
|
|
31
|
+
where `dataBase` is derived from the log path. Entry must exist
|
|
32
|
+
(created by the producer plugin on proposal acceptance, at status=102).
|
|
33
|
+
|
|
34
|
+
Unix FD convention for the channel number: 1=stdout, 2=stderr, higher
|
|
35
|
+
numbers for additional producer channels.
|
|
36
|
+
|
|
37
|
+
### `stream/completed { run, path, exit_code?, duration? }`
|
|
38
|
+
|
|
39
|
+
Transition all `{dataBase}_*` data channels to terminal status:
|
|
40
|
+
- `exit_code=0` (or omitted) → status=200
|
|
41
|
+
- `exit_code≠0` → status=500
|
|
42
|
+
|
|
43
|
+
Rewrite the log entry at `path` with a summary: command, exit code,
|
|
44
|
+
duration, and channel sizes.
|
|
45
|
+
|
|
46
|
+
### `stream/aborted { run, path, reason?, duration? }`
|
|
47
|
+
|
|
48
|
+
Client-initiated cancellation. Transition all `{dataBase}_*` data
|
|
49
|
+
channels to status **499 (Client Closed Request)** — the de-facto HTTP
|
|
50
|
+
status for a request terminated by the client. Rewrite the log entry
|
|
51
|
+
body to note the abort (with optional `reason` and `duration`).
|
|
52
|
+
|
|
53
|
+
Client contract: kill the underlying process first, then call
|
|
54
|
+
`stream/aborted`. Body of each data channel is preserved at whatever
|
|
55
|
+
content was streamed before the kill.
|
|
56
|
+
|
|
57
|
+
### `stream/cancel { run, path, reason? }`
|
|
58
|
+
|
|
59
|
+
Server-initiated cancellation. Any client (or internal server code) can
|
|
60
|
+
cancel a streaming producer — the server transitions channels to **499**
|
|
61
|
+
immediately and pushes a `stream/cancelled` notification to all connected
|
|
62
|
+
clients so they can kill their local processes.
|
|
63
|
+
|
|
64
|
+
Also serves as **stale 102 cleanup**: if the originating client died
|
|
65
|
+
mid-stream (`stream/completed` never arrived), any client can call
|
|
66
|
+
`stream/cancel` to mark orphaned entries terminal.
|
|
67
|
+
|
|
68
|
+
## Producer Plugin Contract
|
|
69
|
+
|
|
70
|
+
A streaming producer plugin:
|
|
71
|
+
|
|
72
|
+
1. On model dispatch, writes the **proposal/log entry** at
|
|
73
|
+
`log://turn_N/{action}/{slug}` at status=202 (this is automatic —
|
|
74
|
+
`TurnExecutor` builds the path via `logPath`; the producer's
|
|
75
|
+
`handler` just persists it).
|
|
76
|
+
2. On `proposal.accepted`, derives the data base
|
|
77
|
+
(`logPathToDataBase(ctx.path)`) and creates **data entries** at
|
|
78
|
+
`{dataBase}_1`, `{dataBase}_2`, etc. at status=102, category=data,
|
|
79
|
+
visibility=summarized, empty body. Then rewrites the log entry body
|
|
80
|
+
to reference the channel paths.
|
|
81
|
+
3. Client or external producer calls the `stream` RPC with chunks as
|
|
82
|
+
they arrive.
|
|
83
|
+
4. When the producer is done, the client/producer calls
|
|
84
|
+
`stream/completed`.
|
|
85
|
+
|
|
86
|
+
Current producers:
|
|
87
|
+
- **sh** — shell commands with side effects (stdout ch1, stderr ch2)
|
|
88
|
+
- **env** — safe shell (stdout ch1, stderr ch2)
|
|
89
|
+
|
|
90
|
+
Future producers that could adopt this pattern:
|
|
91
|
+
- **search** — web search results streaming in (primary ch1, warnings ch2)
|
|
92
|
+
- **fetch** — large page fetch (body ch1, redirects/headers ch2)
|
|
93
|
+
- **tail** — log file following (lines ch1)
|
|
94
|
+
- **watch** — file system events (events ch1)
|
|
95
|
+
|
|
96
|
+
## Not a Model-Facing Tool
|
|
97
|
+
|
|
98
|
+
No scheme registration, no tooldoc, no dispatch handler. The model
|
|
99
|
+
interacts with streamed output via `<get>` on the data entries; the
|
|
100
|
+
stream plugin is purely RPC infrastructure that clients and producer
|
|
101
|
+
plugins use.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { logPathToDataBase } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stream plugin — generic streaming entry infrastructure.
|
|
5
|
+
*
|
|
6
|
+
* Receives chunks from the client (or any producer) and appends them to
|
|
7
|
+
* existing data entries. Producers (sh/env handlers) create the data
|
|
8
|
+
* entries at status=102 on proposal acceptance; this plugin handles the
|
|
9
|
+
* subsequent append + terminal-status transition via two RPC methods.
|
|
10
|
+
*
|
|
11
|
+
* RPC `path` param is the **log-entry path** (log://turn_N/{action}/{slug}
|
|
12
|
+
* — that's what the client sees on `run/proposal`). Channels live under
|
|
13
|
+
* the producer scheme ({action}://turn_N/{slug}_N) for a clean
|
|
14
|
+
* data-vs-logging namespace split; this plugin derives the data base from
|
|
15
|
+
* the log path on every RPC call.
|
|
16
|
+
*
|
|
17
|
+
* Not a model-facing tool. No scheme, no tooldoc, no dispatch handler.
|
|
18
|
+
* Pure RPC plumbing that any streaming-producer plugin can leverage.
|
|
19
|
+
*/
|
|
20
|
+
export default class Stream {
|
|
21
|
+
#core;
|
|
22
|
+
|
|
23
|
+
constructor(core) {
|
|
24
|
+
this.#core = core;
|
|
25
|
+
const hooks = core.hooks;
|
|
26
|
+
const r = hooks.rpc.registry;
|
|
27
|
+
|
|
28
|
+
// stream: append a chunk to a streaming entry.
|
|
29
|
+
// Entry path is constructed as `${path}_${channel}` per the Unix FD
|
|
30
|
+
// convention (1=stdout, 2=stderr, higher=other producer channels).
|
|
31
|
+
r.register("stream", {
|
|
32
|
+
handler: async (params, ctx) => {
|
|
33
|
+
if (!params.run) throw new Error("run is required");
|
|
34
|
+
if (!params.path) throw new Error("path is required");
|
|
35
|
+
if (params.channel == null)
|
|
36
|
+
throw new Error("channel is required (numeric)");
|
|
37
|
+
if (params.chunk == null) throw new Error("chunk is required");
|
|
38
|
+
|
|
39
|
+
const runRow = await ctx.db.get_run_by_alias.get({
|
|
40
|
+
alias: params.run,
|
|
41
|
+
});
|
|
42
|
+
if (!runRow) throw new Error(`run not found: ${params.run}`);
|
|
43
|
+
|
|
44
|
+
const dataBase = logPathToDataBase(params.path);
|
|
45
|
+
if (!dataBase) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`path must be a log entry (log://turn_N/...); got: ${params.path}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const entryPath = `${dataBase}_${params.channel}`;
|
|
51
|
+
await ctx.projectAgent.entries.set({
|
|
52
|
+
runId: runRow.id,
|
|
53
|
+
path: entryPath,
|
|
54
|
+
body: params.chunk,
|
|
55
|
+
append: true,
|
|
56
|
+
});
|
|
57
|
+
return { status: "ok" };
|
|
58
|
+
},
|
|
59
|
+
description:
|
|
60
|
+
"Append a chunk to a streaming entry channel. Used by clients and producers to grow a 102 entry's body.",
|
|
61
|
+
params: {
|
|
62
|
+
run: "string — run alias",
|
|
63
|
+
path: "string — log-entry path (log://turn_N/{action}/{slug}); server derives the data channel path",
|
|
64
|
+
channel: "number — channel index (Unix FD: 1=stdout, 2=stderr)",
|
|
65
|
+
chunk: "string — content to append to the entry body",
|
|
66
|
+
},
|
|
67
|
+
requiresInit: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// stream/completed: transition all data channels for this producer
|
|
71
|
+
// to their terminal status and finalize the log entry body.
|
|
72
|
+
r.register("stream/completed", {
|
|
73
|
+
handler: async (params, ctx) => {
|
|
74
|
+
if (!params.run) throw new Error("run is required");
|
|
75
|
+
if (!params.path) throw new Error("path is required");
|
|
76
|
+
|
|
77
|
+
const runRow = await ctx.db.get_run_by_alias.get({
|
|
78
|
+
alias: params.run,
|
|
79
|
+
});
|
|
80
|
+
if (!runRow) throw new Error(`run not found: ${params.run}`);
|
|
81
|
+
const runId = runRow.id;
|
|
82
|
+
|
|
83
|
+
const { exit_code: exitCode = 0, duration = null } = params;
|
|
84
|
+
const terminalState = exitCode === 0 ? "resolved" : "failed";
|
|
85
|
+
const terminalOutcome = exitCode === 0 ? null : `exit:${exitCode}`;
|
|
86
|
+
|
|
87
|
+
const dataBase = logPathToDataBase(params.path);
|
|
88
|
+
if (!dataBase) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`path must be a log entry (log://turn_N/...); got: ${params.path}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
// Find all `{dataBase}_*` data entries (channels 1, 2, ...).
|
|
94
|
+
const store = ctx.projectAgent.entries;
|
|
95
|
+
const channels = await store.getEntriesByPattern(
|
|
96
|
+
runId,
|
|
97
|
+
`${dataBase}_*`,
|
|
98
|
+
null,
|
|
99
|
+
);
|
|
100
|
+
for (const ch of channels) {
|
|
101
|
+
await store.set({
|
|
102
|
+
runId,
|
|
103
|
+
path: ch.path,
|
|
104
|
+
state: terminalState,
|
|
105
|
+
body: ch.body,
|
|
106
|
+
outcome: terminalOutcome,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Update the log entry body with final stats. Keep it terse —
|
|
111
|
+
// one line summarizing exit code, duration, and channel sizes.
|
|
112
|
+
const logEntry = await store.getAttributes(runId, params.path);
|
|
113
|
+
let command = "";
|
|
114
|
+
if (logEntry?.command) command = logEntry.command;
|
|
115
|
+
else if (logEntry?.summary) command = logEntry.summary;
|
|
116
|
+
const channelSummary = channels
|
|
117
|
+
.map((c) => {
|
|
118
|
+
const size = c.body ? `${c.tokens} tokens` : "empty";
|
|
119
|
+
return `${c.path} (${size})`;
|
|
120
|
+
})
|
|
121
|
+
.join(", ");
|
|
122
|
+
const dur = duration ? ` (${duration})` : "";
|
|
123
|
+
const exitLabel = exitCode === 0 ? "exit=0" : `exit=${exitCode}`;
|
|
124
|
+
const body = `ran '${command}', ${exitLabel}${dur}. Output: ${channelSummary}`;
|
|
125
|
+
await store.set({ runId, path: params.path, state: "resolved", body });
|
|
126
|
+
|
|
127
|
+
return { ok: true, channels: channels.length };
|
|
128
|
+
},
|
|
129
|
+
description:
|
|
130
|
+
"Finalize a streaming producer. Transitions all `{path}_*` data channels to terminal status (200 on exit_code=0, 500 otherwise) and rewrites the log entry body with exit code, duration, and channel sizes.",
|
|
131
|
+
params: {
|
|
132
|
+
run: "string — run alias",
|
|
133
|
+
path: "string — log-entry path (log://turn_N/{action}/{slug}); server derives the data channel path",
|
|
134
|
+
exit_code:
|
|
135
|
+
"number? — exit code (0=success→200, non-zero=failure→500). Defaults to 0 for non-process producers.",
|
|
136
|
+
duration: "string? — human-readable duration for the log entry",
|
|
137
|
+
},
|
|
138
|
+
requiresInit: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// stream/aborted: client-initiated cancellation. Transitions all data
|
|
142
|
+
// channels to status 499 (Client Closed Request — the de-facto HTTP
|
|
143
|
+
// status for client-terminated requests) and rewrites the log entry
|
|
144
|
+
// body to note the abort. Shape mirrors stream/completed for client
|
|
145
|
+
// symmetry: same run/path addressing, same channel sweep.
|
|
146
|
+
r.register("stream/aborted", {
|
|
147
|
+
handler: async (params, ctx) => {
|
|
148
|
+
if (!params.run) throw new Error("run is required");
|
|
149
|
+
if (!params.path) throw new Error("path is required");
|
|
150
|
+
|
|
151
|
+
const runRow = await ctx.db.get_run_by_alias.get({
|
|
152
|
+
alias: params.run,
|
|
153
|
+
});
|
|
154
|
+
if (!runRow) throw new Error(`run not found: ${params.run}`);
|
|
155
|
+
const runId = runRow.id;
|
|
156
|
+
|
|
157
|
+
const { duration = null, reason = null } = params;
|
|
158
|
+
|
|
159
|
+
const dataBase = logPathToDataBase(params.path);
|
|
160
|
+
if (!dataBase) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`path must be a log entry (log://turn_N/...); got: ${params.path}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const store = ctx.projectAgent.entries;
|
|
166
|
+
const channels = await store.getEntriesByPattern(
|
|
167
|
+
runId,
|
|
168
|
+
`${dataBase}_*`,
|
|
169
|
+
null,
|
|
170
|
+
);
|
|
171
|
+
for (const ch of channels) {
|
|
172
|
+
await store.set({
|
|
173
|
+
runId,
|
|
174
|
+
path: ch.path,
|
|
175
|
+
state: "cancelled",
|
|
176
|
+
body: ch.body,
|
|
177
|
+
outcome: reason ? reason : "aborted",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const logEntry = await store.getAttributes(runId, params.path);
|
|
182
|
+
let command = "";
|
|
183
|
+
if (logEntry?.command) command = logEntry.command;
|
|
184
|
+
else if (logEntry?.summary) command = logEntry.summary;
|
|
185
|
+
const channelSummary = channels
|
|
186
|
+
.map((c) => {
|
|
187
|
+
const size = c.body ? `${c.tokens} tokens` : "empty";
|
|
188
|
+
return `${c.path} (${size})`;
|
|
189
|
+
})
|
|
190
|
+
.join(", ");
|
|
191
|
+
const qualifiers = [];
|
|
192
|
+
if (reason) qualifiers.push(reason);
|
|
193
|
+
if (duration) qualifiers.push(duration);
|
|
194
|
+
const qualifier = qualifiers.length
|
|
195
|
+
? ` (${qualifiers.join(", ")})`
|
|
196
|
+
: "";
|
|
197
|
+
const body = `aborted '${command}'${qualifier}. Output: ${channelSummary}`;
|
|
198
|
+
await store.set({ runId, path: params.path, state: "resolved", body });
|
|
199
|
+
|
|
200
|
+
return { status: "ok", channels: channels.length };
|
|
201
|
+
},
|
|
202
|
+
description:
|
|
203
|
+
"Abort a streaming producer. Transitions all `{path}_*` data channels to status 499 (Client Closed Request) and rewrites the log entry body to note the abort.",
|
|
204
|
+
params: {
|
|
205
|
+
run: "string — run alias",
|
|
206
|
+
path: "string — log-entry path (log://turn_N/{action}/{slug}); server derives the data channel path",
|
|
207
|
+
reason:
|
|
208
|
+
"string? — human-readable abort reason (e.g. 'user cancelled', 'timeout')",
|
|
209
|
+
duration: "string? — human-readable duration at abort time",
|
|
210
|
+
},
|
|
211
|
+
requiresInit: true,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// stream/cancel: server-initiated cancellation. Any client (or
|
|
215
|
+
// internal server code) can cancel a streaming producer — the server
|
|
216
|
+
// transitions channels to 499 immediately and pushes a
|
|
217
|
+
// stream/cancelled notification so connected clients can kill their
|
|
218
|
+
// local processes. Also serves as stale 102 cleanup: if the client
|
|
219
|
+
// died mid-stream, call stream/cancel to mark orphaned entries terminal.
|
|
220
|
+
r.register("stream/cancel", {
|
|
221
|
+
handler: async (params, ctx) => {
|
|
222
|
+
if (!params.run) throw new Error("run is required");
|
|
223
|
+
if (!params.path) throw new Error("path is required");
|
|
224
|
+
|
|
225
|
+
const runRow = await ctx.db.get_run_by_alias.get({
|
|
226
|
+
alias: params.run,
|
|
227
|
+
});
|
|
228
|
+
if (!runRow) throw new Error(`run not found: ${params.run}`);
|
|
229
|
+
const runId = runRow.id;
|
|
230
|
+
|
|
231
|
+
const { reason = null } = params;
|
|
232
|
+
|
|
233
|
+
const dataBase = logPathToDataBase(params.path);
|
|
234
|
+
if (!dataBase) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`path must be a log entry (log://turn_N/...); got: ${params.path}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const store = ctx.projectAgent.entries;
|
|
240
|
+
const channels = await store.getEntriesByPattern(
|
|
241
|
+
runId,
|
|
242
|
+
`${dataBase}_*`,
|
|
243
|
+
null,
|
|
244
|
+
);
|
|
245
|
+
for (const ch of channels) {
|
|
246
|
+
await store.set({
|
|
247
|
+
runId,
|
|
248
|
+
path: ch.path,
|
|
249
|
+
state: "cancelled",
|
|
250
|
+
body: ch.body,
|
|
251
|
+
outcome: reason ? reason : "cancelled",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const logEntry = await store.getAttributes(runId, params.path);
|
|
256
|
+
let command = "";
|
|
257
|
+
if (logEntry?.command) command = logEntry.command;
|
|
258
|
+
else if (logEntry?.summary) command = logEntry.summary;
|
|
259
|
+
const channelSummary = channels
|
|
260
|
+
.map((c) => {
|
|
261
|
+
const size = c.body ? `${c.tokens} tokens` : "empty";
|
|
262
|
+
return `${c.path} (${size})`;
|
|
263
|
+
})
|
|
264
|
+
.join(", ");
|
|
265
|
+
const qualifier = reason ? ` (${reason})` : "";
|
|
266
|
+
const body = `cancelled '${command}'${qualifier}. Output: ${channelSummary}`;
|
|
267
|
+
await store.set({ runId, path: params.path, state: "resolved", body });
|
|
268
|
+
|
|
269
|
+
// Notify connected clients so they can kill local processes.
|
|
270
|
+
hooks.stream.cancelled.emit({
|
|
271
|
+
projectId: ctx.projectId,
|
|
272
|
+
run: params.run,
|
|
273
|
+
path: params.path,
|
|
274
|
+
reason,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return { ok: true, channels: channels.length };
|
|
278
|
+
},
|
|
279
|
+
description:
|
|
280
|
+
"Server-initiated cancellation. Transitions all `{path}_*` data channels to status 499 and pushes a stream/cancelled notification to connected clients. Also used for stale 102 cleanup when the originating client is gone.",
|
|
281
|
+
params: {
|
|
282
|
+
run: "string — run alias",
|
|
283
|
+
path: "string — log-entry path (log://turn_N/{action}/{slug}); server derives the data channel path",
|
|
284
|
+
reason:
|
|
285
|
+
"string? — cancellation reason (e.g. 'budget exceeded', 'stale cleanup', 'user cancelled from another client')",
|
|
286
|
+
},
|
|
287
|
+
requiresInit: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|