pi-onlyne 0.9.1 → 1.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/README.md +325 -203
- package/README.zh.md +322 -0
- package/onlyne.json.example +6 -0
- package/package.json +19 -34
- package/relay.toml.example +18 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +923 -0
- package/src/agent.test.mjs +1079 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +300 -0
- package/src/pi-surface.mjs +140 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -124
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -780
- package/dist/onlyne.d.ts +0 -70
- package/dist/onlyne.js +0 -194
- package/dist/session.d.ts +0 -82
- package/dist/session.js +0 -91
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -43
- package/dist/swarm-slot.js +0 -76
- package/dist/swarm.d.ts +0 -96
- package/dist/swarm.js +0 -296
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The pi side of the adapter: every effect the agent asks for, expressed
|
|
2
|
+
// through the pi extension API, with a probe for each optional member so an
|
|
3
|
+
// older pi degrades instead of throwing.
|
|
4
|
+
//
|
|
5
|
+
// Probed members (measured against pi 0.85.1):
|
|
6
|
+
// wakeUser pi.sendUserMessage(content, { deliverAs: "followUp" })
|
|
7
|
+
// proseContext pi.sendMessage({customType,...}, { deliverAs:"followUp", triggerTurn:false })
|
|
8
|
+
// customEntry pi.appendEntry(customType, data)
|
|
9
|
+
// status ctx.ui.setStatus("onlyne", text)
|
|
10
|
+
// exit ctx.shutdown()
|
|
11
|
+
// isIdle ctx.isIdle()
|
|
12
|
+
// registerTool / registerCommand are probed by index.ts itself.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ pi: any, log: (line: string) => void, context: () => any }} options
|
|
16
|
+
*/
|
|
17
|
+
export function createSurface({ pi, log, context }) {
|
|
18
|
+
const has = (value) => typeof value === "function";
|
|
19
|
+
const ctx = () => {
|
|
20
|
+
try {
|
|
21
|
+
return context();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const available = {
|
|
28
|
+
wakeUser: has(pi.sendUserMessage),
|
|
29
|
+
proseContext: has(pi.sendMessage),
|
|
30
|
+
customEntry: has(pi.appendEntry),
|
|
31
|
+
status: true,
|
|
32
|
+
exit: true,
|
|
33
|
+
isIdle: true,
|
|
34
|
+
registerTool: has(pi.registerTool),
|
|
35
|
+
registerCommand: has(pi.registerCommand),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const status = (text) => {
|
|
39
|
+
try {
|
|
40
|
+
ctx()?.ui?.setStatus?.("onlyne", text);
|
|
41
|
+
} catch {
|
|
42
|
+
/* status is decoration; never let it break the protocol */
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** One pi user message; images ride along as pi image content parts. */
|
|
47
|
+
const wakeUser = (text, parts = []) => {
|
|
48
|
+
if (!available.wakeUser) {
|
|
49
|
+
log("pi has no sendUserMessage; the assignment reached the session log only");
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const content = parts.length === 0
|
|
53
|
+
? text
|
|
54
|
+
: [
|
|
55
|
+
{ type: "text", text },
|
|
56
|
+
...parts.map((part) => ({
|
|
57
|
+
type: "image",
|
|
58
|
+
source: { type: "base64", mediaType: part.mime, data: part.data },
|
|
59
|
+
})),
|
|
60
|
+
];
|
|
61
|
+
try {
|
|
62
|
+
pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try {
|
|
66
|
+
pi.sendUserMessage(content);
|
|
67
|
+
return true;
|
|
68
|
+
} catch (fallbackError) {
|
|
69
|
+
const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
70
|
+
log(`sendUserMessage refused: ${message}`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The role prose, once, as a custom message that joins the LLM context
|
|
78
|
+
* without starting a turn of its own.
|
|
79
|
+
*/
|
|
80
|
+
const proseContext = (text, welcome) => {
|
|
81
|
+
if (!available.proseContext) return false;
|
|
82
|
+
try {
|
|
83
|
+
pi.sendMessage(
|
|
84
|
+
{
|
|
85
|
+
customType: "onlyne-role-prose",
|
|
86
|
+
content: `[onlyne] role prose for ${welcome.role} (from the cluster spec, delivered with welcome):\n\n${text}`,
|
|
87
|
+
display: true,
|
|
88
|
+
},
|
|
89
|
+
{ deliverAs: "followUp", triggerTurn: false },
|
|
90
|
+
);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
log(`role prose injection refused: ${message}`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const customEntry = (customType, data) => {
|
|
100
|
+
if (!available.customEntry) return false;
|
|
101
|
+
try {
|
|
102
|
+
pi.appendEntry(customType, data);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
log(`appendEntry refused: ${message}`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
available,
|
|
113
|
+
wakeUser,
|
|
114
|
+
proseContext,
|
|
115
|
+
customEntry,
|
|
116
|
+
status,
|
|
117
|
+
welcome(welcome) {
|
|
118
|
+
status(`onlyne: ${welcome.role}`);
|
|
119
|
+
},
|
|
120
|
+
/** `true` when pi is not processing a run, retry, compaction, or continuation. */
|
|
121
|
+
isIdle() {
|
|
122
|
+
const current = ctx();
|
|
123
|
+
try {
|
|
124
|
+
return has(current?.isIdle) ? current.isIdle() : true;
|
|
125
|
+
} catch {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
exit(reason) {
|
|
130
|
+
log(`exiting pi: ${reason}`);
|
|
131
|
+
try {
|
|
132
|
+
const current = ctx();
|
|
133
|
+
if (has(current?.shutdown)) current.shutdown();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
log(`shutdown refused: ${message}`);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Wire vocabulary of `crates/onlyne-adapter/PROTOCOL.md`, expressed as plain
|
|
2
|
+
// JSON builders. Nothing here opens a socket or touches pi: every function is a
|
|
3
|
+
// pure translation from plugin state to one frame body, which is what makes the
|
|
4
|
+
// protocol testable against the Rust-side fixtures.
|
|
5
|
+
//
|
|
6
|
+
// Frame envelope, plugin -> host (WireMessage in onlyne-adapter/src/lib.rs):
|
|
7
|
+
// request {"id":N,"op":"...","args":{...}}
|
|
8
|
+
// response {"reply_to":N,"ok":true,"data":{...}} | {"reply_to":N,"ok":false,"error":{code,message,field}}
|
|
9
|
+
// Host -> plugin notifications carry `op`/`args` and no `id`.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
/** Protocol revision this plugin speaks; `hello.args.protocol`. */
|
|
16
|
+
export const PROTOCOL_VERSION = 1;
|
|
17
|
+
/** Plugin name on the wire; `hello.args.plugin`. */
|
|
18
|
+
export const PLUGIN_NAME = "pi-onlyne";
|
|
19
|
+
/** Mount class; the host matches the payload's field set against its variants. */
|
|
20
|
+
export const MOUNT_KIND = "agent";
|
|
21
|
+
/** Ledger summary ceiling, the same 200 characters `docs/v1-PLAN.md` fixes. */
|
|
22
|
+
export const MAX_HEAD_CHARS = 200;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Base of the plugin's own report sequence.
|
|
26
|
+
*
|
|
27
|
+
* The host stamps its dispatch events (created, resource attach, ready) into the
|
|
28
|
+
* same `(generation, seq)` watermark the plugin reports against, and the reducer
|
|
29
|
+
* silently drops a report at or below that watermark. The host's own events live
|
|
30
|
+
* in the low single digits, so the plugin's stream starts clear of them; without
|
|
31
|
+
* the offset a turn-state observation would be dropped and never reach the
|
|
32
|
+
* projection.
|
|
33
|
+
*/
|
|
34
|
+
export const SEQ_BASE = 1000;
|
|
35
|
+
|
|
36
|
+
/** Heartbeat cadence while a task is active (`heartbeat_timeout_ms` is 30s). */
|
|
37
|
+
export const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
/** Outcomes the wire accepts for one completion. */
|
|
40
|
+
export const OUTCOMES = ["done", "failed", "cancelled"];
|
|
41
|
+
|
|
42
|
+
/** Mime types `ImagePart` accepts, in the core's stable order. */
|
|
43
|
+
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
44
|
+
|
|
45
|
+
/** Decoded ceiling for one inline image, byte-for-byte `IMAGE_DATA_MAX_BYTES`. */
|
|
46
|
+
export const IMAGE_DATA_MAX_BYTES = 2 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The plugin's own semver, read from the package next to this file so the
|
|
50
|
+
* vendored copy advertises what its `package.json` says.
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function readPluginVersion() {
|
|
54
|
+
try {
|
|
55
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
57
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
58
|
+
} catch {
|
|
59
|
+
return "0.0.0";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One uuid v4, the shape every envelope id and task id takes. */
|
|
64
|
+
export function uuid() {
|
|
65
|
+
return globalThis.crypto.randomUUID();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `hello.args`, the first frame on the connection. The mount is flat and
|
|
70
|
+
* untagged with `kind` as its sibling, exactly as PROTOCOL.md requires.
|
|
71
|
+
* @param {{ role: string, session?: string | null, taskId?: string | null, pid?: number, capabilities: string[] }} options
|
|
72
|
+
*/
|
|
73
|
+
export function helloArgs({ role, session = null, taskId = null, pid, capabilities }) {
|
|
74
|
+
const mount = { role };
|
|
75
|
+
if (session) mount.session = session;
|
|
76
|
+
if (taskId) mount.task_id = taskId;
|
|
77
|
+
if (typeof pid === "number") mount.pid = pid;
|
|
78
|
+
return {
|
|
79
|
+
protocol: PROTOCOL_VERSION,
|
|
80
|
+
plugin: PLUGIN_NAME,
|
|
81
|
+
version: readPluginVersion(),
|
|
82
|
+
kind: MOUNT_KIND,
|
|
83
|
+
capabilities,
|
|
84
|
+
mount,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `welcome` args the host answers `hello` with. The host sends it inside a
|
|
90
|
+
* response body as `{op:"welcome",args:{...}}`; this accepts either the whole
|
|
91
|
+
* body or the already-unwrapped args object.
|
|
92
|
+
* @param {any} data
|
|
93
|
+
*/
|
|
94
|
+
export function welcomeFrom(data) {
|
|
95
|
+
const args = data && typeof data === "object" && data.args && data.op === "welcome" ? data.args : data;
|
|
96
|
+
if (!args || typeof args !== "object" || typeof args.role !== "string") return null;
|
|
97
|
+
return {
|
|
98
|
+
protocol: args.protocol ?? PROTOCOL_VERSION,
|
|
99
|
+
role: args.role,
|
|
100
|
+
sessionId: args.session_id ?? null,
|
|
101
|
+
generation: typeof args.generation === "number" ? args.generation : 1,
|
|
102
|
+
prose: typeof args.prose === "string" ? args.prose : "",
|
|
103
|
+
server: args.server ?? null,
|
|
104
|
+
hostCapabilities: Array.isArray(args.host_capabilities) ? args.host_capabilities : [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `report.ready` — the ready barrier the payload waits behind. */
|
|
109
|
+
export function readyReport({ taskId, sessionId, generation, seq }) {
|
|
110
|
+
return { kind: "ready", data: { task_id: taskId, session_id: sessionId, generation, seq } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `report.heartbeat`. `observed` is a full `Observation` (`onlyne-session`'s
|
|
115
|
+
* reducer type), not a loose status string: the host deserialises it and rejects
|
|
116
|
+
* anything that is not a legal state tuple.
|
|
117
|
+
*/
|
|
118
|
+
export function heartbeatReport({ taskId, generation, seq, agent, host = null }) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "heartbeat",
|
|
121
|
+
data: {
|
|
122
|
+
task_id: taskId,
|
|
123
|
+
generation,
|
|
124
|
+
seq,
|
|
125
|
+
observed: observationFor(agent, { generation, seq, host }),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The final observation of a settled session: `agent: idle` beside the outcome
|
|
132
|
+
* the completion just stated.
|
|
133
|
+
*
|
|
134
|
+
* A session that only ever reported `running` and then completed leaves the
|
|
135
|
+
* ledger's projection saying `running` forever, because nothing observes the
|
|
136
|
+
* exit. This body is the tuple the host's own settle produces
|
|
137
|
+
* (`onlyne-session/src/reconcile.rs::settle_body`) with the agent dimension
|
|
138
|
+
* moved to `idle`, so `is_legal` accepts it: `outcome: done` requires
|
|
139
|
+
* `delivery: accepted` and an idle agent requires `recovery: draining`, and any
|
|
140
|
+
* other outcome carries the delivery unchanged.
|
|
141
|
+
*/
|
|
142
|
+
export function settledReport({ taskId, outcome, generation, seq, host = null }) {
|
|
143
|
+
const normalized = normalizeOutcome(outcome);
|
|
144
|
+
const done = normalized === "done";
|
|
145
|
+
const observed = {
|
|
146
|
+
version: { generation, seq },
|
|
147
|
+
generation_live: true,
|
|
148
|
+
isolate_after: 1,
|
|
149
|
+
terminate_after: 3,
|
|
150
|
+
mismatch_count: 0,
|
|
151
|
+
agent: "idle",
|
|
152
|
+
delivery: done ? "accepted" : "none",
|
|
153
|
+
resource: "attached",
|
|
154
|
+
recovery: done ? "draining" : "none",
|
|
155
|
+
outcome: normalized,
|
|
156
|
+
// `project(idle, accepted, …, done)` is `exited`; every other outcome keeps
|
|
157
|
+
// the session `working` until its resource closes.
|
|
158
|
+
public: done ? "exited" : "working",
|
|
159
|
+
};
|
|
160
|
+
if (host) observed.host = host;
|
|
161
|
+
return { kind: "heartbeat", data: { task_id: taskId, generation, seq, observed } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `report.complete` — the terminal fact the ledger keeps. */
|
|
165
|
+
export function completeReport({ taskId, outcome, head }) {
|
|
166
|
+
const report = { kind: "complete", data: { task_id: taskId, outcome: normalizeOutcome(outcome) } };
|
|
167
|
+
const text = headOf(head);
|
|
168
|
+
if (text) report.data.head = text;
|
|
169
|
+
return report;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `session_register` — bind this live process to its task and session. */
|
|
173
|
+
export function sessionRegisterArgs({ sessionId, taskId = null, generation, pid, title = null }) {
|
|
174
|
+
const args = { session_id: sessionId, generation };
|
|
175
|
+
if (typeof pid === "number") args.pid = pid;
|
|
176
|
+
if (title) args.title = title;
|
|
177
|
+
if (taskId) args.task_id = taskId;
|
|
178
|
+
return args;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `assign_ack` — the plugin's answer to one `assign`. */
|
|
182
|
+
export function assignAckArgs({ taskId, accepted, reason = null }) {
|
|
183
|
+
const args = { task_id: taskId, accepted };
|
|
184
|
+
if (reason) args.reason = reason;
|
|
185
|
+
return args;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `detach` — the plugin is leaving while its session continues. */
|
|
189
|
+
export function detachArgs(reason) {
|
|
190
|
+
return { reason };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A legal `Observation` for one agent state.
|
|
195
|
+
*
|
|
196
|
+
* `onlyne-session`'s `is_legal` requires `public` to be `project(...)` of the
|
|
197
|
+
* other dimensions and non-zero reconcile policy, so the tuple is built rather
|
|
198
|
+
* than passed through: the plugin owns the agent dimension (the host never
|
|
199
|
+
* synthesises turn state), and leaves delivery at `none`/outcome `pending`,
|
|
200
|
+
* which is its own truth until it reports a completion.
|
|
201
|
+
*
|
|
202
|
+
* `host` is where this process runs (`hostBinding`); it is attached only when
|
|
203
|
+
* the environment names a pane, so a pi outside Orca reports a tuple with no
|
|
204
|
+
* host field at all.
|
|
205
|
+
* @param {"booting"|"ready"|"running"|"idle"|"gone"} agent
|
|
206
|
+
*/
|
|
207
|
+
export function observationFor(agent, { generation, seq, host = null }) {
|
|
208
|
+
const state = agent === "booting" || agent === "gone" ? "booting" : agent;
|
|
209
|
+
const observed = {
|
|
210
|
+
version: { generation, seq },
|
|
211
|
+
generation_live: true,
|
|
212
|
+
// onlyne-session/src/reconcile.rs: DEFAULT_ISOLATE_AFTER / DEFAULT_TERMINATE_AFTER.
|
|
213
|
+
isolate_after: 1,
|
|
214
|
+
terminate_after: 3,
|
|
215
|
+
mismatch_count: 0,
|
|
216
|
+
agent: state,
|
|
217
|
+
delivery: "none",
|
|
218
|
+
resource: "attached",
|
|
219
|
+
recovery: "none",
|
|
220
|
+
outcome: "pending",
|
|
221
|
+
public: state === "running" ? "working" : state === "ready" || state === "idle" ? "idle" : "created",
|
|
222
|
+
};
|
|
223
|
+
if (host) observed.host = host;
|
|
224
|
+
return observed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The Orca pane this process was spawned in, in the shape `onlyne-session`'s
|
|
229
|
+
* `HostRef` deserialises (`{"orca":{…}}`), or null outside a pane.
|
|
230
|
+
*
|
|
231
|
+
* An Orca pane exports `ORCA_PANE_KEY` — `<tab_id>:<leaf_id>`, beside
|
|
232
|
+
* `ORCA_TAB_ID` and `ORCA_TERMINAL_HANDLE` — into the command it was started
|
|
233
|
+
* with (measured 2026-09-11 on Orca 1.4.198), and this plugin is spawned with
|
|
234
|
+
* the environment it inherited, so the binding is inherited rather than
|
|
235
|
+
* guessed: this process is the only component that can state, from the inside,
|
|
236
|
+
* which Orca pane an onlyne session is. No field is invented: the ids come from
|
|
237
|
+
* the key and from the environment, and a missing handle is simply absent.
|
|
238
|
+
*
|
|
239
|
+
* @param {Record<string, string | undefined>} env
|
|
240
|
+
*/
|
|
241
|
+
export function hostBinding(env) {
|
|
242
|
+
const named = typeof env?.ORCA_PANE_KEY === "string" ? env.ORCA_PANE_KEY.trim() : "";
|
|
243
|
+
const [keyTab, keyLeaf] = named.includes(":") ? named.split(":") : [];
|
|
244
|
+
const tabId = env?.ORCA_TAB_ID || keyTab || "";
|
|
245
|
+
const leafId = env?.ORCA_LEAF_ID || keyLeaf || "";
|
|
246
|
+
if (!tabId || !leafId) return null;
|
|
247
|
+
const orca = { pane_key: `${tabId}:${leafId}`, tab_id: tabId, leaf_id: leafId };
|
|
248
|
+
const handle = env?.ORCA_TERMINAL_HANDLE;
|
|
249
|
+
if (typeof handle === "string" && handle) orca.handle = handle;
|
|
250
|
+
return { orca };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One inline image part, from raw bytes. */
|
|
254
|
+
export function imagePart({ data, mime, name = null }) {
|
|
255
|
+
if (!IMAGE_MIMES.includes(mime)) {
|
|
256
|
+
throw new Error(`image mime ${mime} unsupported; expected one of ${IMAGE_MIMES.join(", ")}`);
|
|
257
|
+
}
|
|
258
|
+
if (data.length > IMAGE_DATA_MAX_BYTES) {
|
|
259
|
+
throw new Error(`image exceeds ${IMAGE_DATA_MAX_BYTES} bytes`);
|
|
260
|
+
}
|
|
261
|
+
const part = { data_base64: Buffer.from(data).toString("base64"), mime };
|
|
262
|
+
if (name) part.name = name;
|
|
263
|
+
return part;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `{"role":{"role":name}}`, the role principal spelling every envelope uses. */
|
|
267
|
+
export function principalRole(role) {
|
|
268
|
+
return { role: { role } };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One outbound envelope for the `send` frame.
|
|
273
|
+
*
|
|
274
|
+
* `note` is free text and carries no idempotency key; `task` hands work to a
|
|
275
|
+
* role and requires `op_id` plus a causality chain (`Envelope::validate`).
|
|
276
|
+
* @param {{ from: string, to: string, kind?: "note" | "task", text?: string, image?: object | null, taskId?: string | null }} options
|
|
277
|
+
*/
|
|
278
|
+
export function sendEnvelope({ from, to, kind = "note", text = "", image = null, taskId = null }) {
|
|
279
|
+
const body = {};
|
|
280
|
+
if (text) body.text = text;
|
|
281
|
+
if (image) body.image = image;
|
|
282
|
+
if (!body.text && !body.image) throw new Error("body requires text or image");
|
|
283
|
+
const envelope = {
|
|
284
|
+
protocol: PROTOCOL_VERSION,
|
|
285
|
+
id: uuid(),
|
|
286
|
+
kind,
|
|
287
|
+
from: principalRole(from),
|
|
288
|
+
to: principalRole(to),
|
|
289
|
+
body,
|
|
290
|
+
ts: new Date().toISOString(),
|
|
291
|
+
admin: false,
|
|
292
|
+
};
|
|
293
|
+
if (kind === "task") {
|
|
294
|
+
envelope.op_id = `o-${uuid()}`;
|
|
295
|
+
envelope.causality = { task: taskId || uuid(), hop: 0, attempt: 0 };
|
|
296
|
+
}
|
|
297
|
+
return envelope;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A human-readable principal, for injection headers. */
|
|
301
|
+
export function describePrincipal(principal) {
|
|
302
|
+
if (!principal || typeof principal !== "object") return "unknown";
|
|
303
|
+
if (principal.role) {
|
|
304
|
+
const role = principal.role.role ?? "?";
|
|
305
|
+
return principal.role.session ? `role:${role}/${principal.role.session}` : `role:${role}`;
|
|
306
|
+
}
|
|
307
|
+
if (principal.gateway) {
|
|
308
|
+
const { gateway, channel, conversation } = principal.gateway;
|
|
309
|
+
return `gateway:${gateway}:${channel}${conversation ? `:${conversation}` : ""}`;
|
|
310
|
+
}
|
|
311
|
+
if (principal.cluster) return `cluster:${principal.cluster}`;
|
|
312
|
+
return "unknown";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One line of text, trimmed and cut to the ledger ceiling. */
|
|
316
|
+
export function headOf(text) {
|
|
317
|
+
if (typeof text !== "string") return "";
|
|
318
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
319
|
+
return flat.length > MAX_HEAD_CHARS ? flat.slice(0, MAX_HEAD_CHARS) : flat;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** One known outcome spelling, defaulting to `done`. */
|
|
323
|
+
export function normalizeOutcome(value) {
|
|
324
|
+
return typeof value === "string" && OUTCOMES.includes(value) ? value : "done";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The user message one `assign` becomes.
|
|
329
|
+
*
|
|
330
|
+
* The task text is quoted verbatim under a header naming its origin, so a
|
|
331
|
+
* session transcript shows where the instruction came from; the role prose
|
|
332
|
+
* (identical in `welcome` and `assign`) is folded in only when it has not
|
|
333
|
+
* already been delivered.
|
|
334
|
+
* @param {{ assign: any, proseIsNew: boolean, attachmentPaths?: string[] }} options
|
|
335
|
+
*/
|
|
336
|
+
export function injectionText({ assign, proseIsNew, attachmentPaths = [] }) {
|
|
337
|
+
const envelope = assign.envelope ?? {};
|
|
338
|
+
const taskId = assign.task_id ?? envelope.causality?.task ?? "unknown";
|
|
339
|
+
const lines = [
|
|
340
|
+
`[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"})`,
|
|
341
|
+
];
|
|
342
|
+
const prose = typeof assign.prose === "string" ? assign.prose.trim() : "";
|
|
343
|
+
if (prose && proseIsNew) {
|
|
344
|
+
lines.push("", "[onlyne] role prose from the spec:", prose);
|
|
345
|
+
}
|
|
346
|
+
const text = typeof envelope.body?.text === "string" ? envelope.body.text.trim() : "";
|
|
347
|
+
lines.push("", text || "(the task carried no text; the image attachment is the payload)");
|
|
348
|
+
if (attachmentPaths.length > 0) {
|
|
349
|
+
lines.push("", `[onlyne] attachment saved to: ${attachmentPaths.join(", ")}`);
|
|
350
|
+
}
|
|
351
|
+
return lines.join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Task body for the `config_get` route: when a plugin does not declare `inject`
|
|
356
|
+
* the host hands the payload over as `config_get{key:"stdin:<task text>"}`
|
|
357
|
+
* (PROTOCOL.md, "Mounts and capabilities").
|
|
358
|
+
* @returns {{ text: string } | null}
|
|
359
|
+
*/
|
|
360
|
+
export function stdinTaskText(args) {
|
|
361
|
+
const key = args?.key;
|
|
362
|
+
if (typeof key !== "string" || !key.startsWith("stdin:")) return null;
|
|
363
|
+
return { text: key.slice("stdin:".length) };
|
|
364
|
+
}
|