@crouton-kit/crouter 0.3.13 → 0.3.14
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/dist/commands/__tests__/human.test.js +73 -2
- package/dist/commands/human/queue.d.ts +1 -0
- package/dist/commands/human/queue.js +89 -2
- package/dist/commands/human/shared.d.ts +5 -0
- package/dist/commands/human/shared.js +15 -0
- package/dist/commands/human.js +4 -2
- package/dist/commands/node.js +195 -24
- package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +141 -0
- package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +97 -0
- package/dist/core/canvas/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -0
- package/dist/core/command.js +40 -7
- package/dist/core/feed/feed.js +11 -9
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +79 -0
- package/dist/core/help.d.ts +45 -12
- package/dist/core/help.js +42 -4
- package/dist/core/runtime/demote.d.ts +14 -0
- package/dist/core/runtime/demote.js +103 -0
- package/dist/core/runtime/kickoff.d.ts +9 -0
- package/dist/core/runtime/kickoff.js +19 -1
- package/dist/core/runtime/launch.d.ts +12 -1
- package/dist/core/runtime/launch.js +18 -2
- package/dist/core/runtime/presence.d.ts +1 -18
- package/dist/core/runtime/presence.js +7 -51
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.js +7 -2
- package/dist/core/runtime/tmux.d.ts +11 -12
- package/dist/core/runtime/tmux.js +57 -26
- package/dist/pi-extensions/canvas-commands.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +100 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
- package/dist/pi-extensions/canvas-goal-capture.js +53 -0
- package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- package/package.json +1 -1
|
@@ -98,25 +98,6 @@ export function openNodeWindow(opts) {
|
|
|
98
98
|
]);
|
|
99
99
|
return r.ok ? r.stdout : null;
|
|
100
100
|
}
|
|
101
|
-
/** Open a background window running a plain login shell (no pi) and return its
|
|
102
|
-
* window + pane ids. Used by demote: the agent's pi is swapped OUT into this
|
|
103
|
-
* window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
|
|
104
|
-
* off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
|
|
105
|
-
export function openShellWindow(opts) {
|
|
106
|
-
const r = tmux([
|
|
107
|
-
'new-window', '-d', '-a', '-P',
|
|
108
|
-
'-F', '#{window_id}\t#{pane_id}',
|
|
109
|
-
'-t', `${opts.session}:`,
|
|
110
|
-
'-n', opts.name,
|
|
111
|
-
'-c', opts.cwd,
|
|
112
|
-
]);
|
|
113
|
-
if (!r.ok)
|
|
114
|
-
return null;
|
|
115
|
-
const [window, pane] = r.stdout.split('\t');
|
|
116
|
-
if (window === undefined || pane === undefined)
|
|
117
|
-
return null;
|
|
118
|
-
return { window, pane };
|
|
119
|
-
}
|
|
120
101
|
/** Bring a node's window forefront. Switches client across roots when needed. */
|
|
121
102
|
export function focusWindow(session, window) {
|
|
122
103
|
const here = currentTmux();
|
|
@@ -145,6 +126,17 @@ export function windowOfPane(pane) {
|
|
|
145
126
|
const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
|
|
146
127
|
return r.ok && r.stdout !== '' ? r.stdout : null;
|
|
147
128
|
}
|
|
129
|
+
/** The session + window a pane currently lives in. Used by demote to place the
|
|
130
|
+
* recycled root's meta on the pane it respawns into. Null if tmux fails. */
|
|
131
|
+
export function paneLocation(pane) {
|
|
132
|
+
const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
return null;
|
|
135
|
+
const [session, window] = r.stdout.split('\t');
|
|
136
|
+
if (session === undefined || session === '' || window === undefined || window === '')
|
|
137
|
+
return null;
|
|
138
|
+
return { session, window };
|
|
139
|
+
}
|
|
148
140
|
/** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
|
|
149
141
|
* caller's window active, so the target's pane appears where the caller is
|
|
150
142
|
* rather than navigating the client off to the target's window. The caller's
|
|
@@ -232,13 +224,52 @@ export function switchClient(session) {
|
|
|
232
224
|
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
233
225
|
export function installMenuBinding() {
|
|
234
226
|
const sess = nodeSession();
|
|
235
|
-
|
|
227
|
+
const title = ' crtr ';
|
|
228
|
+
const items = [
|
|
229
|
+
// Promote types `/promote` into the agent's pane rather than shelling out:
|
|
230
|
+
// the slash command delivers the orchestration guidance into the node's
|
|
231
|
+
// context, which a bare `run-shell` (output discarded) could not.
|
|
232
|
+
{ name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
|
|
233
|
+
{ name: 'finish agent + recycle pane', key: 'd', cmd: `run-shell "crtr node demote --pane '#{pane_id}'"` },
|
|
234
|
+
{ name: 'browse background agents', key: 'g', cmd: `switch-client -t ${sess}` },
|
|
235
|
+
];
|
|
236
|
+
// tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
|
|
237
|
+
// top-right corner, shift x left by the box width (longest line + tmux chrome:
|
|
238
|
+
// borders + padding + the right-aligned mnemonic-key column) via format math.
|
|
239
|
+
const boxW = Math.max(title.length, ...items.map((i) => i.name.length)) + 6;
|
|
240
|
+
// Fine-tune nudges off the pane's top-right corner: a hair further left and
|
|
241
|
+
// one row down so the box doesn't kiss the pane border.
|
|
242
|
+
const nudgeX = 1; // extra columns left
|
|
243
|
+
const nudgeY = 3; // rows down
|
|
244
|
+
const args = [
|
|
236
245
|
'bind-key', '-n', 'M-c', 'display-menu',
|
|
237
|
-
'-T',
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
'-T', `#[align=centre]${title}`,
|
|
247
|
+
'-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`,
|
|
248
|
+
'-y', `#{e|+:#{pane_top},${nudgeY}}`,
|
|
249
|
+
];
|
|
250
|
+
for (const it of items)
|
|
251
|
+
args.push(it.name, it.key, it.cmd);
|
|
252
|
+
return tmux(args).ok;
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
|
|
256
|
+
// key shells out to `crtr node cycle`, passing the active pane so the walk is
|
|
257
|
+
// relative to the agent in front of you; cycle then swaps the next/prev node
|
|
258
|
+
// into that pane (like `node focus`). Output is discarded so the keypress never
|
|
259
|
+
// pops a results view. Installed at root boot alongside the Alt+C menu.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
/** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
|
|
262
|
+
* false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
|
|
263
|
+
* terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
|
|
264
|
+
*/
|
|
265
|
+
export function installNavBindings() {
|
|
266
|
+
const next = tmux([
|
|
267
|
+
'bind-key', '-n', 'M-]', 'run-shell',
|
|
268
|
+
`crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
|
|
269
|
+
]).ok;
|
|
270
|
+
const prev = tmux([
|
|
271
|
+
'bind-key', '-n', 'M-[', 'run-shell',
|
|
272
|
+
`crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
|
|
243
273
|
]).ok;
|
|
274
|
+
return next && prev;
|
|
244
275
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface AutocompleteItem {
|
|
2
|
+
value: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
interface CommandUI {
|
|
6
|
+
notify(message: string, type?: 'info' | 'warning' | 'error'): void;
|
|
7
|
+
setStatus(key: string, text: string | undefined): void;
|
|
8
|
+
}
|
|
9
|
+
interface CommandCtx {
|
|
10
|
+
ui: CommandUI;
|
|
11
|
+
}
|
|
12
|
+
interface CustomMessage {
|
|
13
|
+
customType: string;
|
|
14
|
+
content: string;
|
|
15
|
+
display?: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface PiLike {
|
|
18
|
+
registerCommand(name: string, options: {
|
|
19
|
+
description?: string;
|
|
20
|
+
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
|
|
21
|
+
handler: (args: string, ctx: CommandCtx) => Promise<void>;
|
|
22
|
+
}): void;
|
|
23
|
+
sendMessage(message: CustomMessage, options?: {
|
|
24
|
+
triggerTurn?: boolean;
|
|
25
|
+
}): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Register the canvas slash-commands on `pi`.
|
|
29
|
+
*
|
|
30
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
31
|
+
* inert in a non-canvas pi session.
|
|
32
|
+
*/
|
|
33
|
+
export declare function registerCanvasCommands(pi: PiLike): void;
|
|
34
|
+
export default registerCanvasCommands;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// canvas-commands.ts — pi extension registering canvas slash-commands on nodes.
|
|
2
|
+
//
|
|
3
|
+
// /promote [kind] — promote THIS node to a resident orchestrator. Runs
|
|
4
|
+
// `crtr node promote --json` for CRTR_NODE_ID (optionally specializing its
|
|
5
|
+
// kind), then injects the orchestration guidance the command returns into
|
|
6
|
+
// context and triggers a turn, so the node authors its roadmap immediately.
|
|
7
|
+
// This is the same mid-turn guidance dump the node would get by running the
|
|
8
|
+
// command itself by hand — surfaced as a one-keystroke affordance.
|
|
9
|
+
//
|
|
10
|
+
// The Alt+C tmux action menu's "promote to orchestrator" item (key `o`) simply
|
|
11
|
+
// send-keys `/promote` into the active pane, so the menu and the slash command
|
|
12
|
+
// share this one implementation.
|
|
13
|
+
//
|
|
14
|
+
// INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
|
|
15
|
+
//
|
|
16
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
17
|
+
// inside crouter's own tsc build without a dep on the pi packages (mirrors
|
|
18
|
+
// canvas-nav.ts). The only crouter import is availableKinds, used to offer
|
|
19
|
+
// `/promote <kind>` completions.
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import { availableKinds } from '../core/personas/index.js';
|
|
23
|
+
const pexec = promisify(execFile);
|
|
24
|
+
// Kinds for `/promote <kind>` completions — computed once (persona dirs rarely
|
|
25
|
+
// change within a session), best-effort so a loader hiccup never breaks input.
|
|
26
|
+
let cachedKinds = null;
|
|
27
|
+
function kinds() {
|
|
28
|
+
if (cachedKinds === null) {
|
|
29
|
+
try {
|
|
30
|
+
cachedKinds = availableKinds();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
cachedKinds = [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return cachedKinds;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Register the canvas slash-commands on `pi`.
|
|
40
|
+
*
|
|
41
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
42
|
+
* inert in a non-canvas pi session.
|
|
43
|
+
*/
|
|
44
|
+
export function registerCanvasCommands(pi) {
|
|
45
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
46
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
47
|
+
return; // not a canvas node
|
|
48
|
+
pi.registerCommand('promote', {
|
|
49
|
+
description: 'Promote this node to a resident orchestrator — /promote, or /promote <kind> to specialize',
|
|
50
|
+
getArgumentCompletions: (prefix) => {
|
|
51
|
+
const items = kinds()
|
|
52
|
+
.filter((k) => k.startsWith(prefix))
|
|
53
|
+
.map((k) => ({ value: k, label: k }));
|
|
54
|
+
return items.length > 0 ? items : null;
|
|
55
|
+
},
|
|
56
|
+
handler: async (args, ctx) => {
|
|
57
|
+
const kind = args.trim().toLowerCase();
|
|
58
|
+
ctx.ui.setStatus('crtr-promote', kind ? `promoting → ${kind}…` : 'promoting…');
|
|
59
|
+
const argv = ['node', 'promote', '--json'];
|
|
60
|
+
if (kind !== '')
|
|
61
|
+
argv.push('--kind', kind);
|
|
62
|
+
// Run promote out-of-process. On a non-zero exit, crtr still prints the
|
|
63
|
+
// structured error to stdout, so prefer its `message` over the raw throw.
|
|
64
|
+
let result = null;
|
|
65
|
+
let errMsg = null;
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await pexec('crtr', argv, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 });
|
|
68
|
+
result = JSON.parse(stdout);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const e = err;
|
|
72
|
+
const stdout = typeof e.stdout === 'string' ? e.stdout : '';
|
|
73
|
+
try {
|
|
74
|
+
const payload = JSON.parse(stdout);
|
|
75
|
+
errMsg = typeof payload.message === 'string' ? payload.message : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* stdout wasn't JSON */
|
|
79
|
+
}
|
|
80
|
+
if (errMsg === null)
|
|
81
|
+
errMsg = typeof e.message === 'string' ? e.message : String(err);
|
|
82
|
+
}
|
|
83
|
+
ctx.ui.setStatus('crtr-promote', '');
|
|
84
|
+
if (result === null) {
|
|
85
|
+
ctx.ui.notify(`promote failed: ${errMsg ?? 'unknown error'}`, 'error');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const rmPath = (result.roadmap_path ?? '').trim();
|
|
89
|
+
ctx.ui.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`, 'info');
|
|
90
|
+
// The guidance is operating instructions for the node, not the user.
|
|
91
|
+
// Inject it silently and trigger a turn so the node acts on it now —
|
|
92
|
+
// exactly what happens when the node runs `crtr node promote` by hand.
|
|
93
|
+
const guidance = (result.guidance ?? '').trim();
|
|
94
|
+
if (guidance === '')
|
|
95
|
+
return;
|
|
96
|
+
pi.sendMessage({ customType: 'crtr-promote', content: guidance, display: false }, { triggerTurn: true });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export default registerCanvasCommands;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface InputEventLike {
|
|
2
|
+
type: 'input';
|
|
3
|
+
text: string;
|
|
4
|
+
images?: unknown[];
|
|
5
|
+
source: 'interactive' | 'rpc' | 'extension';
|
|
6
|
+
}
|
|
7
|
+
interface PiLike {
|
|
8
|
+
on: (event: 'input', handler: (event: InputEventLike, ctx: any) => void) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Register the goal-capture handler on `pi`.
|
|
12
|
+
*
|
|
13
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
14
|
+
* is the whole extension: on the first interactive message of a goal-less node,
|
|
15
|
+
* persist it as the goal.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerCanvasGoalCapture(pi: PiLike): void;
|
|
18
|
+
export default registerCanvasGoalCapture;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// canvas-goal-capture.ts — pi extension for pi-native canvas agent nodes.
|
|
2
|
+
//
|
|
3
|
+
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
|
+
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
|
+
//
|
|
6
|
+
// A node spawned with a prompt has its goal persisted at birth (writeGoal in
|
|
7
|
+
// spawn.ts). A bare root (`crtr` with no prompt) starts goal-less — its mandate
|
|
8
|
+
// only arrives when the human types their first message. This extension closes
|
|
9
|
+
// that gap: on the FIRST interactive user message, if the node has no goal yet,
|
|
10
|
+
// it persists that message as context/initial-prompt.md. Subsequent messages
|
|
11
|
+
// never clobber it (captureGoalIfAbsent is guarded), and a fresh-revive kickoff
|
|
12
|
+
// prompt is skipped via its sentinel so it can never be mistaken for a mandate.
|
|
13
|
+
//
|
|
14
|
+
// Pure observation — it writes the goal file as a side effect and always lets
|
|
15
|
+
// the message through unchanged (returns nothing ⇒ continue). Registered before
|
|
16
|
+
// canvas-passive-context so it reads the raw user text, not a backlog-prepended
|
|
17
|
+
// transform.
|
|
18
|
+
//
|
|
19
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
20
|
+
// crouter's own tsc build without a dep on the pi packages.
|
|
21
|
+
import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
|
|
22
|
+
/**
|
|
23
|
+
* Register the goal-capture handler on `pi`.
|
|
24
|
+
*
|
|
25
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
26
|
+
* is the whole extension: on the first interactive message of a goal-less node,
|
|
27
|
+
* persist it as the goal.
|
|
28
|
+
*/
|
|
29
|
+
export function registerCanvasGoalCapture(pi) {
|
|
30
|
+
pi.on('input', (event) => {
|
|
31
|
+
try {
|
|
32
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
33
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
34
|
+
return; // not a canvas node
|
|
35
|
+
// Only a genuine human-typed prompt seeds the mandate — never an RPC or an
|
|
36
|
+
// extension-injected message (inbox wakes, steering nudges, kickoffs).
|
|
37
|
+
if (event.source !== 'interactive')
|
|
38
|
+
return;
|
|
39
|
+
const text = (event.text ?? '').trim();
|
|
40
|
+
if (text === '')
|
|
41
|
+
return;
|
|
42
|
+
// A fresh-revive kickoff is delivered as the launch prompt; never let it
|
|
43
|
+
// masquerade as the user's first mandate.
|
|
44
|
+
if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
|
|
45
|
+
return;
|
|
46
|
+
captureGoalIfAbsent(nodeId, text);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Best-effort: a capture failure must never drop or alter the message.
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export default registerCanvasGoalCapture;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { InboxEntry } from '../core/feed/inbox.js';
|
|
2
|
+
interface InputEventLike {
|
|
3
|
+
type: 'input';
|
|
4
|
+
text: string;
|
|
5
|
+
images?: unknown[];
|
|
6
|
+
source: 'interactive' | 'rpc' | 'extension';
|
|
7
|
+
}
|
|
8
|
+
type InputEventResultLike = {
|
|
9
|
+
action: 'continue';
|
|
10
|
+
} | {
|
|
11
|
+
action: 'transform';
|
|
12
|
+
text: string;
|
|
13
|
+
images?: unknown[];
|
|
14
|
+
} | {
|
|
15
|
+
action: 'handled';
|
|
16
|
+
};
|
|
17
|
+
interface PiLike {
|
|
18
|
+
on: (event: 'input', handler: (event: InputEventLike, ctx: any) => InputEventResultLike | void) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render drained passive entries (oldest first) into one XML pre-text block.
|
|
22
|
+
* Each accumulated message is its own timestamped `<update>` element.
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatPassive(entries: InboxEntry[]): string;
|
|
25
|
+
/**
|
|
26
|
+
* Register the passive-context drain on `pi`.
|
|
27
|
+
*
|
|
28
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
29
|
+
* is the whole extension: drain on every message, prepend when non-empty.
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerCanvasPassiveContext(pi: PiLike): void;
|
|
32
|
+
export default registerCanvasPassiveContext;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// canvas-passive-context.ts — pi extension for pi-native canvas agent nodes.
|
|
2
|
+
//
|
|
3
|
+
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
|
+
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
|
+
//
|
|
6
|
+
// The passive-subscription drain. A PASSIVE subscription (active=false edge)
|
|
7
|
+
// never wakes its subscriber: `push` routes its pointers to passive.jsonl, which
|
|
8
|
+
// the inbox-watcher never polls. They simply accumulate. This extension is what
|
|
9
|
+
// finally surfaces them — the moment the node is MESSAGED.
|
|
10
|
+
//
|
|
11
|
+
// pi fires an `input` event for every user message (human-typed, an RPC, or an
|
|
12
|
+
// extension's sendUserMessage — including the inbox-watcher's own wake). On that
|
|
13
|
+
// event we DRAIN the node's passive accumulator and, when non-empty, prepend
|
|
14
|
+
// every entry as timestamped XML to the message text via the `transform` action.
|
|
15
|
+
// So the backlog rides in as pre-text on whatever message next engages the node,
|
|
16
|
+
// before the LLM sees it — and is cleared in the same step (drain = read+clear),
|
|
17
|
+
// so it surfaces exactly once.
|
|
18
|
+
//
|
|
19
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
20
|
+
// crouter's own tsc build without a dep on the pi packages.
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { drainPassive } from '../core/feed/passive.js';
|
|
23
|
+
// Per-entry body cap so a single fat report can't blow the context budget. The
|
|
24
|
+
// full report stays on disk at `ref` if the agent needs more.
|
|
25
|
+
const BODY_CAP = 4_000;
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Report dereference — turn a passive pointer into the message text it carries.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/** Strip the leading YAML frontmatter block a report is written with, returning
|
|
30
|
+
* just the body. Tolerant: no frontmatter → returns the input unchanged. */
|
|
31
|
+
function stripFrontmatter(raw) {
|
|
32
|
+
if (!raw.startsWith('---\n'))
|
|
33
|
+
return raw;
|
|
34
|
+
const end = raw.indexOf('\n---\n', 4);
|
|
35
|
+
return end === -1 ? raw : raw.slice(end + 5);
|
|
36
|
+
}
|
|
37
|
+
/** The content for one accumulated entry: the dereferenced report body when the
|
|
38
|
+
* pointer carries a `ref`, else the entry's own label/data. Capped + trimmed. */
|
|
39
|
+
function entryContent(e) {
|
|
40
|
+
if (e.ref !== undefined && e.ref !== '' && existsSync(e.ref)) {
|
|
41
|
+
try {
|
|
42
|
+
const body = stripFrontmatter(readFileSync(e.ref, 'utf8')).trim();
|
|
43
|
+
if (body !== '') {
|
|
44
|
+
return body.length > BODY_CAP
|
|
45
|
+
? `${body.slice(0, BODY_CAP)}\n… (truncated; full report at ${e.ref})`
|
|
46
|
+
: body;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* fall through to the label */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const data = e.data?.['body'];
|
|
54
|
+
if (typeof data === 'string' && data.trim() !== '')
|
|
55
|
+
return data.trim();
|
|
56
|
+
return e.label;
|
|
57
|
+
}
|
|
58
|
+
/** Minimal XML attribute escaping for the values we interpolate. */
|
|
59
|
+
function attr(s) {
|
|
60
|
+
return s
|
|
61
|
+
.replace(/&/g, '&')
|
|
62
|
+
.replace(/</g, '<')
|
|
63
|
+
.replace(/>/g, '>')
|
|
64
|
+
.replace(/"/g, '"');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Render drained passive entries (oldest first) into one XML pre-text block.
|
|
68
|
+
* Each accumulated message is its own timestamped `<update>` element.
|
|
69
|
+
*/
|
|
70
|
+
export function formatPassive(entries) {
|
|
71
|
+
const blocks = entries
|
|
72
|
+
.map((e) => {
|
|
73
|
+
const from = attr(e.from ?? 'system');
|
|
74
|
+
const refAttr = e.ref !== undefined && e.ref !== '' ? ` ref="${attr(e.ref)}"` : '';
|
|
75
|
+
return (`<update from="${from}" kind="${attr(e.kind)}" at="${attr(e.ts)}"${refAttr}>\n` +
|
|
76
|
+
`${entryContent(e)}\n` +
|
|
77
|
+
`</update>`);
|
|
78
|
+
})
|
|
79
|
+
.join('\n');
|
|
80
|
+
return (`<passive-subscription-backlog count="${entries.length}" ` +
|
|
81
|
+
`note="Reports accumulated from nodes you passively subscribe to while you were not actively listening. ` +
|
|
82
|
+
`Surfaced now because you were messaged. Oldest first.">\n` +
|
|
83
|
+
`${blocks}\n` +
|
|
84
|
+
`</passive-subscription-backlog>`);
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Extension
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Register the passive-context drain on `pi`.
|
|
91
|
+
*
|
|
92
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
93
|
+
* is the whole extension: drain on every message, prepend when non-empty.
|
|
94
|
+
*/
|
|
95
|
+
export function registerCanvasPassiveContext(pi) {
|
|
96
|
+
pi.on('input', (event) => {
|
|
97
|
+
try {
|
|
98
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
99
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
100
|
+
return; // not a canvas node
|
|
101
|
+
const drained = drainPassive(nodeId);
|
|
102
|
+
if (drained.length === 0)
|
|
103
|
+
return; // nothing accumulated → leave the message as-is
|
|
104
|
+
const preText = formatPassive(drained);
|
|
105
|
+
const text = event.text.trim() === '' ? preText : `${preText}\n\n${event.text}`;
|
|
106
|
+
return { action: 'transform', text, images: event.images };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort: a drain/format failure must never drop the user's message.
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export default registerCanvasPassiveContext;
|