channels.tools 0.1.0 → 0.1.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/package.json +1 -1
- package/src/envelope.test.ts +27 -1
- package/src/envelope.ts +19 -0
- package/src/index.test.ts +16 -4
- package/src/index.ts +22 -7
- package/src/wake.test.ts +5 -5
- package/src/wake.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "channels.tools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Channels for the pi coding agent: connect channel servers that push events into a live session — wake it when idle, queue when busy — and proxy their tools. A client for the claude/channel convention.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/envelope.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { splitGuidance, renderBatch, strictestIntent } from "./envelope.ts";
|
|
3
|
+
import { splitGuidance, renderBatch, renderSummary, strictestIntent } from "./envelope.ts";
|
|
4
4
|
|
|
5
5
|
test("splitGuidance separates on the first --- sentinel", () => {
|
|
6
6
|
const r = splitGuidance("line one\n\n---\nGUIDANCE HERE");
|
|
@@ -130,3 +130,29 @@ test("events without a server count fall back to counting notifications", () =>
|
|
|
130
130
|
]);
|
|
131
131
|
assert.match(out, /count="2"/);
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("renderSummary takes each event's first body line and drops the guidance", () => {
|
|
135
|
+
const out = renderSummary("muster-channel", [
|
|
136
|
+
{ source: "muster-channel", content: 'muster: reply on thread #1 "x"\n\n---\ncall get_thread 1', meta: {} },
|
|
137
|
+
{ source: "muster-channel", content: 'muster: fyi on thread #2 "y"\n\n---\nread only', meta: {} },
|
|
138
|
+
]);
|
|
139
|
+
assert.equal(out, 'muster: reply on thread #1 "x"\nmuster: fyi on thread #2 "y"');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("renderSummary dedupes identical lines and caps at five with a remainder", () => {
|
|
143
|
+
const events = Array.from({ length: 8 }, (_, i) => ({
|
|
144
|
+
source: "s",
|
|
145
|
+
content: `line ${i}`,
|
|
146
|
+
meta: {},
|
|
147
|
+
}));
|
|
148
|
+
events.push({ source: "s", content: "line 0", meta: {} });
|
|
149
|
+
const out = renderSummary("s", events);
|
|
150
|
+
const lines = out.split("\n");
|
|
151
|
+
assert.equal(lines.length, 6);
|
|
152
|
+
assert.equal(lines[5], "…and 3 more");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("renderSummary falls back to naming the source when no event has a body", () => {
|
|
156
|
+
const out = renderSummary("galley", [{ source: "galley", content: "\n\n---\nguidance only", meta: {} }]);
|
|
157
|
+
assert.equal(out, "[galley] new channel activity");
|
|
158
|
+
});
|
package/src/envelope.ts
CHANGED
|
@@ -43,6 +43,25 @@ function escapeAttr(value: string): string {
|
|
|
43
43
|
.replace(/"/g, """);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// renderSummary is the VISIBLE half of a delivery: one short line per event
|
|
47
|
+
// (the first body line, which channel servers author as their own compact
|
|
48
|
+
// summary), deduplicated, capped at five. It is what the operator reads in
|
|
49
|
+
// the transcript, and what the model acts on in the rare race where the
|
|
50
|
+
// envelope's nextTurn queue drains only at a later prompt — so it must be
|
|
51
|
+
// able to stand alone, not just decorate.
|
|
52
|
+
export function renderSummary(source: string, events: ChannelEvent[]): string {
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
const { body } = splitGuidance(event.content);
|
|
56
|
+
const first = body.split("\n").find((line) => line.trim() !== "");
|
|
57
|
+
if (first !== undefined && !lines.includes(first)) lines.push(first);
|
|
58
|
+
}
|
|
59
|
+
if (lines.length === 0) return `[${source}] new channel activity`;
|
|
60
|
+
const shown = lines.slice(0, 5);
|
|
61
|
+
if (lines.length > shown.length) shown.push(`…and ${lines.length - shown.length} more`);
|
|
62
|
+
return shown.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
export function renderBatch(source: string, events: ChannelEvent[]): string {
|
|
47
66
|
const bodies: string[] = [];
|
|
48
67
|
const guidance: string[] = [];
|
package/src/index.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ type ToolDef = { name: string; execute(id: string, params: unknown): Promise<unk
|
|
|
15
15
|
function fakePi() {
|
|
16
16
|
const handlers = new Map<string, Handler[]>();
|
|
17
17
|
const sent: Array<{ msg: unknown; opts: unknown }> = [];
|
|
18
|
+
const customs: Array<{ msg: unknown; opts: unknown }> = [];
|
|
18
19
|
const registered: string[] = [];
|
|
19
20
|
const unregistered: string[] = [];
|
|
20
21
|
const tools = new Map<string, ToolDef>();
|
|
@@ -26,6 +27,7 @@ function fakePi() {
|
|
|
26
27
|
handlers.set(event, list);
|
|
27
28
|
},
|
|
28
29
|
sendUserMessage(content: unknown, opts: unknown) { sent.push({ msg: content, opts }); },
|
|
30
|
+
sendMessage(message: unknown, opts: unknown) { customs.push({ msg: message, opts }); },
|
|
29
31
|
registerTool(def: ToolDef) { registered.push(def.name); tools.set(def.name, def); },
|
|
30
32
|
unregisterTool(name: string) { unregistered.push(name); tools.delete(name); },
|
|
31
33
|
getAllTools() { return [{ name: "bash" }, { name: "read" }]; },
|
|
@@ -43,7 +45,7 @@ function fakePi() {
|
|
|
43
45
|
for (const h of handlers.get(event) ?? []) out.push(await h(payload, ctx));
|
|
44
46
|
return out;
|
|
45
47
|
}
|
|
46
|
-
return { pi, ctx, fire, sent, registered, unregistered, tools, statuses };
|
|
48
|
+
return { pi, ctx, fire, sent, customs, registered, unregistered, tools, statuses };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
test("session_start sets AGENT_SESSION_ID before any server is spawned", async () => {
|
|
@@ -63,19 +65,29 @@ test("session_start sets AGENT_SESSION_ID before any server is spawned", async (
|
|
|
63
65
|
await fire("session_shutdown");
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
test("an inbound event
|
|
67
|
-
const { pi, fire, sent } = fakePi();
|
|
68
|
+
test("an inbound event delivers a hidden envelope plus a visible summary line", async () => {
|
|
69
|
+
const { pi, fire, sent, customs } = fakePi();
|
|
68
70
|
createExtension(pi as never, {
|
|
69
71
|
env: {},
|
|
70
72
|
loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE], env: { FAKE_PUSH_AFTER_MS: "50" } } }),
|
|
71
73
|
});
|
|
72
74
|
await fire("session_start", { reason: "startup" });
|
|
73
75
|
await new Promise((r) => setTimeout(r, 500));
|
|
76
|
+
// The envelope rides a hidden custom message queued for the next turn —
|
|
77
|
+
// pi injects the nextTurn queue before before_agent_start fires.
|
|
78
|
+
assert.equal(customs.length, 1);
|
|
79
|
+
const custom = customs[0].msg as { customType: string; content: string; display: boolean };
|
|
80
|
+
assert.equal(custom.customType, "channel-envelope");
|
|
81
|
+
assert.equal(custom.display, false);
|
|
82
|
+
assert.match(custom.content, /<channel source="fake"/);
|
|
83
|
+
assert.equal((customs[0].opts as { deliverAs?: string }).deliverAs, "nextTurn");
|
|
84
|
+
// The visible user message is the short summary — no raw XML — and it is
|
|
85
|
+
// what triggers the turn through the full prompt pipeline.
|
|
74
86
|
assert.equal(sent.length, 1);
|
|
75
87
|
const opts = sent[0].opts as { deliverAs?: string };
|
|
76
88
|
assert.equal(opts.deliverAs, "steer");
|
|
77
89
|
const text = sent[0].msg as string;
|
|
78
|
-
assert.
|
|
90
|
+
assert.doesNotMatch(text, /<channel/);
|
|
79
91
|
await fire("session_shutdown");
|
|
80
92
|
});
|
|
81
93
|
|
package/src/index.ts
CHANGED
|
@@ -43,14 +43,29 @@ export function createExtension(pi: any, deps: Deps = {}): void {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
const wake = createWake({
|
|
46
|
-
deliver: (
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// (pi
|
|
46
|
+
deliver: ({ envelope, summary }) => {
|
|
47
|
+
// Two-part delivery so the transcript shows a short readable line
|
|
48
|
+
// instead of the raw XML envelope, while the model still receives the
|
|
49
|
+
// envelope verbatim (pi serializes custom messages to the LLM as user
|
|
50
|
+
// content; display:false only hides them from the TUI).
|
|
51
|
+
//
|
|
52
|
+
// Why not one custom message with triggerTurn: that path starts the
|
|
53
|
+
// turn without emitting before_agent_start (verified against pi
|
|
54
|
+
// 0.84.3 dist), so extensions that assemble or capture the system
|
|
50
55
|
// prompt there never see the turn — a bridge provider can hard-fail a
|
|
51
|
-
// session whose first turn is a channel wake.
|
|
52
|
-
//
|
|
53
|
-
|
|
56
|
+
// session whose first turn is a channel wake. Instead the envelope is
|
|
57
|
+
// queued deliverAs:"nextTurn" and the turn is triggered by the
|
|
58
|
+
// summary via sendUserMessage: prompt() drains the nextTurn queue
|
|
59
|
+
// into that same turn BEFORE emitting before_agent_start. Wake only
|
|
60
|
+
// delivers while the agent is settled; if a turn starts in the narrow
|
|
61
|
+
// race window, the summary steers into it and the envelope waits for
|
|
62
|
+
// the next prompt — degraded (the summary stands alone by design),
|
|
63
|
+
// never lost.
|
|
64
|
+
pi.sendMessage(
|
|
65
|
+
{ customType: "channel-envelope", content: envelope, display: false },
|
|
66
|
+
{ deliverAs: "nextTurn" },
|
|
67
|
+
);
|
|
68
|
+
pi.sendUserMessage(summary, { deliverAs: "steer" });
|
|
54
69
|
},
|
|
55
70
|
log: (message) => log(message),
|
|
56
71
|
});
|
package/src/wake.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ChannelEvent } from "./envelope.ts";
|
|
|
5
5
|
|
|
6
6
|
function harness() {
|
|
7
7
|
const delivered: string[] = [];
|
|
8
|
-
const wake = createWake({ deliver: (
|
|
8
|
+
const wake = createWake({ deliver: (d) => delivered.push(d.envelope) });
|
|
9
9
|
wake.onSessionStart();
|
|
10
10
|
wake.onReady();
|
|
11
11
|
return { wake, delivered };
|
|
@@ -61,7 +61,7 @@ test("settling with an empty buffer delivers nothing", () => {
|
|
|
61
61
|
|
|
62
62
|
test("mail arriving before ready is buffered and released once ready", () => {
|
|
63
63
|
const delivered: string[] = [];
|
|
64
|
-
const wake = createWake({ deliver: (
|
|
64
|
+
const wake = createWake({ deliver: (d) => delivered.push(d.envelope) });
|
|
65
65
|
wake.onSessionStart();
|
|
66
66
|
wake.onEvent(ev("muster-channel", "early mail"));
|
|
67
67
|
assert.equal(delivered.length, 0, "must not deliver into a half-built session");
|
|
@@ -94,9 +94,9 @@ test("a throwing deliver for one source still delivers the other (F2)", () => {
|
|
|
94
94
|
const delivered: string[] = [];
|
|
95
95
|
const logs: string[] = [];
|
|
96
96
|
const wake = createWake({
|
|
97
|
-
deliver: (
|
|
98
|
-
if (
|
|
99
|
-
delivered.push(
|
|
97
|
+
deliver: (d) => {
|
|
98
|
+
if (d.envelope.includes('source="bad"')) throw new Error("boom: deliver exploded");
|
|
99
|
+
delivered.push(d.envelope);
|
|
100
100
|
},
|
|
101
101
|
log: (m) => logs.push(m),
|
|
102
102
|
});
|
package/src/wake.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { renderBatch } from "./envelope.ts";
|
|
1
|
+
import { renderBatch, renderSummary } from "./envelope.ts";
|
|
2
2
|
import type { ChannelEvent } from "./envelope.ts";
|
|
3
3
|
|
|
4
|
+
export type Delivery = {
|
|
5
|
+
// The model-facing XML envelope — full bodies, guidance, meta attributes.
|
|
6
|
+
envelope: string;
|
|
7
|
+
// The human-facing short form — what the transcript shows.
|
|
8
|
+
summary: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
4
11
|
export type Wake = {
|
|
5
12
|
onEvent(event: ChannelEvent): void;
|
|
6
13
|
onAgentStart(): void;
|
|
@@ -10,7 +17,7 @@ export type Wake = {
|
|
|
10
17
|
pendingCount(): number;
|
|
11
18
|
};
|
|
12
19
|
|
|
13
|
-
export function createWake(opts: { deliver: (
|
|
20
|
+
export function createWake(opts: { deliver: (delivery: Delivery) => void; log?: (message: string) => void }): Wake {
|
|
14
21
|
// Insertion order of this Map is the delivery order across sources.
|
|
15
22
|
let buffer = new Map<string, ChannelEvent[]>();
|
|
16
23
|
let busy = false;
|
|
@@ -29,7 +36,7 @@ export function createWake(opts: { deliver: (text: string) => void; log?: (messa
|
|
|
29
36
|
for (const [source, events] of batches) {
|
|
30
37
|
if (events.length === 0) continue;
|
|
31
38
|
try {
|
|
32
|
-
opts.deliver(renderBatch(source, events));
|
|
39
|
+
opts.deliver({ envelope: renderBatch(source, events), summary: renderSummary(source, events) });
|
|
33
40
|
} catch (error) {
|
|
34
41
|
// This runs synchronously inside the OS stream callback chain
|
|
35
42
|
// (stdout 'data' -> dispatch -> onEvent -> flush -> deliver ->
|