@crouton-kit/crouter 0.3.12 → 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/builtin-personas/runtime-base.md +2 -2
- 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 +239 -15
- 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 -1
- package/dist/core/runtime/presence.js +6 -4
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/revive.js +6 -8
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +0 -2
- package/dist/core/runtime/spawn.js +26 -16
- package/dist/core/runtime/tmux.d.ts +18 -0
- package/dist/core/runtime/tmux.js +77 -0
- 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/dist/pi-extensions/canvas-stophook.js +42 -19
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -132,29 +132,52 @@ function extractText(msg) {
|
|
|
132
132
|
.trim();
|
|
133
133
|
}
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
|
-
// Context-size steering bands —
|
|
136
|
-
//
|
|
137
|
-
//
|
|
135
|
+
// Context-size steering bands — mode-specific schedules that ESCALATE in tone
|
|
136
|
+
// as input context grows. The first band is a gentle "consider it"; a later
|
|
137
|
+
// band turns firm. Past the last explicit band the firmest nudge repeats every
|
|
138
|
+
// 50k, so a long-lived node keeps getting reminded.
|
|
139
|
+
//
|
|
140
|
+
// orchestrator: 130k gentle (consider yielding) → 150k+ firm (do it now)
|
|
141
|
+
// base worker: 130k suggest promote → 160k+ suggest promote (+ "ignore if
|
|
142
|
+
// nearly done")
|
|
138
143
|
// ---------------------------------------------------------------------------
|
|
139
|
-
const STEER_FLOOR = 100_000;
|
|
140
144
|
const STEER_STEP = 50_000;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
const ORCH_BANDS = [130_000, 150_000]; // gentle, then firm (firm repeats +50k)
|
|
146
|
+
const WORKER_BANDS = [130_000, 160_000]; // suggest, then suggest+ignore (repeats +50k)
|
|
147
|
+
/** The highest band threshold at or below `tokens` for `mode`. Below the first
|
|
148
|
+
* band → null. At/past the last listed band, bands continue every STEER_STEP
|
|
149
|
+
* (so the firmest nudge keeps recurring). */
|
|
150
|
+
function steerBand(tokens, mode) {
|
|
151
|
+
const bands = mode === 'orchestrator' ? ORCH_BANDS : WORKER_BANDS;
|
|
152
|
+
const first = bands[0];
|
|
153
|
+
const last = bands[bands.length - 1];
|
|
154
|
+
if (tokens < first)
|
|
145
155
|
return null;
|
|
146
|
-
|
|
156
|
+
if (tokens >= last)
|
|
157
|
+
return last + Math.floor((tokens - last) / STEER_STEP) * STEER_STEP;
|
|
158
|
+
let chosen = first;
|
|
159
|
+
for (const b of bands)
|
|
160
|
+
if (tokens >= b)
|
|
161
|
+
chosen = b;
|
|
162
|
+
return chosen;
|
|
147
163
|
}
|
|
148
|
-
/** The nudge text for a crossed band, specialized to the node's mode
|
|
149
|
-
* orchestrator is steered to checkpoint its
|
|
150
|
-
*
|
|
151
|
-
*
|
|
164
|
+
/** The nudge text for a crossed band, specialized to the node's mode + how far
|
|
165
|
+
* along the escalation it is. An orchestrator is steered to checkpoint its
|
|
166
|
+
* roadmap and yield (gently first, then firmly); a non-orchestrator (base
|
|
167
|
+
* worker) is steered to PROMOTE itself — become a resident orchestrator — when
|
|
168
|
+
* work remains, with an "ignore if nearly done" once it's deeper in. */
|
|
152
169
|
function steerNote(at, mode) {
|
|
153
170
|
const k = Math.round(at / 1000);
|
|
154
171
|
if (mode === 'orchestrator') {
|
|
172
|
+
if (at < 150_000) {
|
|
173
|
+
return `Context ~${k}k and growing. When you reach a good stopping point, consider updating context/roadmap.md and running \`crtr node yield\` to refresh against it — no rush yet.`;
|
|
174
|
+
}
|
|
155
175
|
return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
|
|
156
176
|
}
|
|
157
|
-
|
|
177
|
+
const suggest = `If much more work remains than this context can finish, consider \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh).`;
|
|
178
|
+
if (at < 160_000)
|
|
179
|
+
return `Context ~${k}k. ${suggest}`;
|
|
180
|
+
return `Context ~${k}k. ${suggest} If you're nearly done, ignore this suggestion and finish with \`crtr push final\`.`;
|
|
158
181
|
}
|
|
159
182
|
// ---------------------------------------------------------------------------
|
|
160
183
|
// Extension
|
|
@@ -179,9 +202,9 @@ export function registerCanvasStophook(pi) {
|
|
|
179
202
|
let totalOut = 0;
|
|
180
203
|
let model = '';
|
|
181
204
|
// Context-size steering. As input context grows we nudge the node once per
|
|
182
|
-
// band
|
|
183
|
-
// read at fire time since a base worker can promote mid-session: an
|
|
184
|
-
// orchestrator
|
|
205
|
+
// band on an escalating, mode-specific schedule (see steerBand/steerNote).
|
|
206
|
+
// Mode is read at fire time since a base worker can promote mid-session: an
|
|
207
|
+
// orchestrator is steered to checkpoint + yield; a base worker to promote.
|
|
185
208
|
const firedBands = new Set();
|
|
186
209
|
// ---------------------------------------------------------------------------
|
|
187
210
|
// session_start — capture pi's session id, and detect `/new`.
|
|
@@ -246,10 +269,10 @@ export function registerCanvasStophook(pi) {
|
|
|
246
269
|
// Context-size steering: fire the current band once, with mode-specific
|
|
247
270
|
// guidance (mode is read live — a worker may have promoted since launch).
|
|
248
271
|
try {
|
|
249
|
-
const
|
|
272
|
+
const mode = getNode(nodeId)?.mode ?? 'base';
|
|
273
|
+
const at = steerBand(totalIn, mode);
|
|
250
274
|
if (at !== null && !firedBands.has(at)) {
|
|
251
275
|
firedBands.add(at);
|
|
252
|
-
const mode = getNode(nodeId)?.mode ?? 'base';
|
|
253
276
|
pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: 'followUp' });
|
|
254
277
|
}
|
|
255
278
|
}
|