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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channels.tools",
3
- "version": "0.1.0",
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",
@@ -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 while idle is delivered as a user message through the full prompt pipeline", async () => {
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.match(text, /<channel source="fake"/);
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: (text) => {
47
- // sendUserMessage, not sendMessage({triggerTurn}): the custom-message
48
- // trigger path starts the turn without emitting before_agent_start
49
- // (pi core skips it), so extensions that assemble or capture the system
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. sendUserMessage runs the
52
- // full prompt pipeline when idle and queues a steer while streaming.
53
- pi.sendUserMessage(text, { deliverAs: "steer" });
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: (text) => delivered.push(text) });
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: (text) => delivered.push(text) });
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: (text) => {
98
- if (text.includes('source="bad"')) throw new Error("boom: deliver exploded");
99
- delivered.push(text);
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: (text: string) => void; log?: (message: string) => void }): Wake {
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 ->