pi-onlyne 0.2.4 → 0.3.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 +25 -8
- package/SPEC.md +5 -1
- package/dist/index.js +18 -7
- package/dist/onlyne.d.ts +3 -2
- package/dist/onlyne.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,16 +19,20 @@ Onlyne is deliberately narrow:
|
|
|
19
19
|
|
|
20
20
|
`pi-onlyne` connects pi to an existing Onlyne workspace and exposes Onlyne as native pi tools.
|
|
21
21
|
|
|
22
|
+
Onlyne channels are singleton-routed: each enabled channel has one `bind_conversation_id` set in config or by sending `/handshake` from the desired conversation, so pi tools take `channelId` only.
|
|
23
|
+
|
|
22
24
|
With this extension, a pi agent can:
|
|
23
25
|
|
|
24
26
|
- watch an Onlyne workspace for inbound IM messages
|
|
25
27
|
- surface inbound messages into the current pi session
|
|
26
28
|
- reply to the current inbound message
|
|
27
|
-
- send a message to a
|
|
29
|
+
- send a message to a channel's configured conversation
|
|
28
30
|
- broadcast the same message to multiple conversations
|
|
31
|
+
- inject a local loopback activation so background scripts can wake the session
|
|
32
|
+
- share Onlyne's FIFO consume cursor so `.onlyne/channels/<channel>/out` does not re-read messages already surfaced to pi
|
|
29
33
|
- mark an inbound message as intentionally not replied
|
|
30
34
|
|
|
31
|
-
Messages are Markdown by default, matching normal agent output. Use `rawText: true` only when the message must be sent literally.
|
|
35
|
+
Messages are Markdown by default, matching normal agent output. Use `rawText: true` only when the message must be sent literally. Onlyne can also expose FIFO IO under `.onlyne/channels/<channel>/in|out`; pi-onlyne stays on the socket/event API and advances the shared consume cursor after delivering inbound follow-ups.
|
|
32
36
|
|
|
33
37
|
## Install
|
|
34
38
|
|
|
@@ -46,6 +50,8 @@ You also need the `onlyne` CLI installed and an initialized workspace:
|
|
|
46
50
|
|
|
47
51
|
```bash
|
|
48
52
|
onlyne init
|
|
53
|
+
# Optional: refresh the workspace-local agent skill
|
|
54
|
+
onlyne export-skill
|
|
49
55
|
```
|
|
50
56
|
|
|
51
57
|
If `onlyne` is not on `PATH`, set:
|
|
@@ -79,8 +85,9 @@ When a message arrives through Onlyne, pi receives it as a follow-up message. Th
|
|
|
79
85
|
|
|
80
86
|
```text
|
|
81
87
|
onlyne_reply({ text })
|
|
82
|
-
onlyne_send({ channelId,
|
|
88
|
+
onlyne_send({ channelId, text, rawText? })
|
|
83
89
|
onlyne_broadcast({ targets, text, rawText? })
|
|
90
|
+
onlyne_loopback({ text, rawText? })
|
|
84
91
|
onlyne_mark_no_reply({ reason? })
|
|
85
92
|
```
|
|
86
93
|
|
|
@@ -89,7 +96,6 @@ onlyne_mark_no_reply({ reason? })
|
|
|
89
96
|
```ts
|
|
90
97
|
onlyne_send({
|
|
91
98
|
channelId: "telegram",
|
|
92
|
-
conversationId: "123456",
|
|
93
99
|
text: "# Build report\n\nAll checks passed."
|
|
94
100
|
})
|
|
95
101
|
```
|
|
@@ -99,7 +105,6 @@ onlyne_send({
|
|
|
99
105
|
```ts
|
|
100
106
|
onlyne_send({
|
|
101
107
|
channelId: "telegram",
|
|
102
|
-
conversationId: "123456",
|
|
103
108
|
text: "# not a heading",
|
|
104
109
|
rawText: true
|
|
105
110
|
})
|
|
@@ -110,13 +115,25 @@ onlyne_send({
|
|
|
110
115
|
```ts
|
|
111
116
|
onlyne_broadcast({
|
|
112
117
|
targets: [
|
|
113
|
-
{ channelId: "telegram"
|
|
114
|
-
{ channelId: "feishu"
|
|
118
|
+
{ channelId: "telegram" },
|
|
119
|
+
{ channelId: "feishu" }
|
|
115
120
|
],
|
|
116
|
-
text: "# Release shipped\n\nVersion 0.
|
|
121
|
+
text: "# Release shipped\n\nVersion 0.3.0 is live."
|
|
117
122
|
})
|
|
118
123
|
```
|
|
119
124
|
|
|
125
|
+
### Loopback wake-up
|
|
126
|
+
|
|
127
|
+
From any local script, inject an inbound message into the current Onlyne daemon:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
|
|
131
|
+
# or, with FIFO IO enabled by the daemon:
|
|
132
|
+
printf 'background job finished\n' > .onlyne/channels/loopback/in
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Pi treats channel `loopback` as wake-up-only: it sends a follow-up to the session, but does not expect `onlyne_reply`.
|
|
136
|
+
|
|
120
137
|
## Local state
|
|
121
138
|
|
|
122
139
|
This extension stores its own pi-side config at:
|
package/SPEC.md
CHANGED
|
@@ -14,6 +14,9 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
|
|
|
14
14
|
- Outbound defaults to `guarded-explicit`: prefer tool reply, fallback to final text, else send configured error text.
|
|
15
15
|
- Send tools default to Markdown and may pass `raw_text: true` to Onlyne for literal text.
|
|
16
16
|
- Broadcast sends concurrently with per-target retry and per-target results.
|
|
17
|
+
- Loopback inbound messages wake Pi without creating a reply obligation.
|
|
18
|
+
- After pi surfaces an inbound follow-up, it calls `mark_io_consumed` so Onlyne FIFO `out_cursor = "consume"` stays synchronized with pi notifications.
|
|
19
|
+
- FIFO IO itself remains owned by the Onlyne daemon; pi-onlyne does not open `.onlyne/channels/*/in|out` directly.
|
|
17
20
|
|
|
18
21
|
## Config
|
|
19
22
|
|
|
@@ -34,8 +37,9 @@ Stored in project `.pi/onlyne.json`:
|
|
|
34
37
|
## Tools
|
|
35
38
|
|
|
36
39
|
- `onlyne_reply({ text })`
|
|
37
|
-
- `onlyne_send({ channelId,
|
|
40
|
+
- `onlyne_send({ channelId, text, rawText? })`
|
|
38
41
|
- `onlyne_broadcast({ targets, text, rawText? })`
|
|
42
|
+
- `onlyne_loopback({ text, rawText? })`
|
|
39
43
|
- `onlyne_mark_no_reply({ reason? })`
|
|
40
44
|
|
|
41
45
|
## Deferred
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { broadcast, connectOrSpawn, sendWithRetry, stopProcess, subscribe } from "./onlyne.js";
|
|
3
|
+
import { broadcast, connectOrSpawn, loopback, markConsumed, sendWithRetry, stopProcess, subscribe } from "./onlyne.js";
|
|
4
4
|
import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
|
|
5
5
|
import { findWorkspace } from "./workspace.js";
|
|
6
6
|
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped" };
|
|
7
7
|
const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
|
|
8
8
|
const currentConfig = () => loadConfig(state.cwd);
|
|
9
|
-
function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, text } : null; }
|
|
9
|
+
function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const messageId = msg.message_id ?? msg.messageId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, messageId, text } : null; }
|
|
10
|
+
function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
|
|
11
|
+
void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
|
|
10
12
|
async function startWatch(pi) { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
11
13
|
throw new Error("current workspace has no .onlyne configuration"); const conn = await connectOrSpawn(state.workspace); state.owner = conn.owner; state.child = conn.process; state.socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
12
14
|
return; const inbound = inboundText(line); if (!inbound)
|
|
13
15
|
return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
|
|
14
|
-
return;
|
|
15
|
-
|
|
16
|
+
return; if (inbound.channelId === "loopback") {
|
|
17
|
+
if (mode === "auto-handle")
|
|
18
|
+
pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
|
|
19
|
+
consumeIfNotified(inbound);
|
|
20
|
+
return;
|
|
21
|
+
} state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
|
|
22
|
+
pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
|
|
23
|
+
consumeIfNotified(inbound);
|
|
24
|
+
} }); state.watching = true; return `watching ${state.workspace.root} (${state.owner})`; }
|
|
16
25
|
function stopWatch() { state.socket?.destroy(); state.socket = undefined; if (state.owner === "extension")
|
|
17
26
|
stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return "watch stopped"; }
|
|
18
27
|
async function reply(text) { if (!state.workspace)
|
|
19
28
|
throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
|
|
20
|
-
throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId
|
|
29
|
+
throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok)
|
|
21
30
|
inbound.replied = true; return res; }
|
|
22
31
|
export default function onlyne(pi) {
|
|
23
32
|
pi.on("session_start", async (_event, ctx) => { state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); ctx.ui.setStatus("onlyne", state.workspace ? "onlyne: ready" : "onlyne: no .onlyne"); if (currentConfig().watch.autoStart && state.workspace) {
|
|
@@ -69,10 +78,12 @@ export default function onlyne(pi) {
|
|
|
69
78
|
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
|
|
70
79
|
} } });
|
|
71
80
|
pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with plain text to the current Onlyne inbound message.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
|
|
72
|
-
pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to
|
|
81
|
+
pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
73
82
|
throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
|
|
74
|
-
pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many Onlyne
|
|
83
|
+
pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many configured Onlyne channels concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
75
84
|
throw new Error("onlyne workspace not found"); const cfg = currentConfig(); const results = await broadcast(state.workspace.socketPath, params.targets, params.text, cfg.outbound.retry.attempts, cfg.outbound.retry.concurrency, params.rawText ?? false); return textResult(JSON.stringify({ ok: results.every((r) => r.ok), results }), results); } }));
|
|
85
|
+
pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown. FIFO alternative: write to .onlyne/channels/loopback/in.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
86
|
+
throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
|
|
76
87
|
pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound)
|
|
77
88
|
state.currentInbound.noReply = true; return textResult("marked no reply", params); } }));
|
|
78
89
|
}
|
package/dist/onlyne.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface OnlyneRequest {
|
|
|
5
5
|
id: string;
|
|
6
6
|
op: string;
|
|
7
7
|
channel_id?: string;
|
|
8
|
-
|
|
8
|
+
message_id?: string;
|
|
9
9
|
text?: string;
|
|
10
10
|
format?: "plain" | "markdown";
|
|
11
11
|
raw_text?: boolean;
|
|
@@ -13,7 +13,6 @@ export interface OnlyneRequest {
|
|
|
13
13
|
}
|
|
14
14
|
export interface SendTarget {
|
|
15
15
|
channelId: string;
|
|
16
|
-
conversationId: string;
|
|
17
16
|
}
|
|
18
17
|
export interface SendResult extends SendTarget {
|
|
19
18
|
ok: boolean;
|
|
@@ -28,5 +27,7 @@ export declare function connectOrSpawn(ws: Workspace): Promise<{
|
|
|
28
27
|
process?: ChildProcess;
|
|
29
28
|
}>;
|
|
30
29
|
export declare function stopProcess(child?: ChildProcess): void;
|
|
30
|
+
export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
|
|
31
|
+
export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
|
|
31
32
|
export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
|
|
32
33
|
export declare function broadcast(socketPath: string, targets: SendTarget[], text: string, attempts: number, concurrency: number, rawText?: boolean): Promise<SendResult[]>;
|
package/dist/onlyne.js
CHANGED
|
@@ -80,11 +80,17 @@ export async function connectOrSpawn(ws) {
|
|
|
80
80
|
export function stopProcess(child) { if (!child || child.killed)
|
|
81
81
|
return; child.kill("SIGTERM"); setTimeout(() => { if (!child.killed)
|
|
82
82
|
child.kill("SIGKILL"); }, 1500).unref(); }
|
|
83
|
+
export async function loopback(socketPath, text, rawText = true) {
|
|
84
|
+
return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
|
|
85
|
+
}
|
|
86
|
+
export async function markConsumed(socketPath, messageId) {
|
|
87
|
+
return request(socketPath, { id: `consume-${Date.now()}`, op: "mark_io_consumed", message_id: messageId });
|
|
88
|
+
}
|
|
83
89
|
export async function sendWithRetry(socketPath, target, text, attempts, rawText = false) {
|
|
84
90
|
let error = "unknown error";
|
|
85
91
|
for (let i = 0; i < Math.max(1, attempts); i++) {
|
|
86
92
|
try {
|
|
87
|
-
const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId,
|
|
93
|
+
const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId, text, raw_text: rawText });
|
|
88
94
|
if (res.ok)
|
|
89
95
|
return { ...target, ok: true };
|
|
90
96
|
error = res.error?.message ?? JSON.stringify(res.error ?? res);
|