@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.
Files changed (45) hide show
  1. package/dist/builtin-personas/runtime-base.md +2 -2
  2. package/dist/commands/__tests__/human.test.js +73 -2
  3. package/dist/commands/human/queue.d.ts +1 -0
  4. package/dist/commands/human/queue.js +89 -2
  5. package/dist/commands/human/shared.d.ts +5 -0
  6. package/dist/commands/human/shared.js +15 -0
  7. package/dist/commands/human.js +4 -2
  8. package/dist/commands/node.js +239 -15
  9. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  10. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  11. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  12. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  13. package/dist/core/canvas/paths.d.ts +4 -0
  14. package/dist/core/canvas/paths.js +6 -0
  15. package/dist/core/command.js +40 -7
  16. package/dist/core/feed/feed.js +11 -9
  17. package/dist/core/feed/passive.d.ts +17 -0
  18. package/dist/core/feed/passive.js +79 -0
  19. package/dist/core/help.d.ts +45 -12
  20. package/dist/core/help.js +42 -4
  21. package/dist/core/runtime/demote.d.ts +14 -0
  22. package/dist/core/runtime/demote.js +103 -0
  23. package/dist/core/runtime/kickoff.d.ts +9 -0
  24. package/dist/core/runtime/kickoff.js +19 -1
  25. package/dist/core/runtime/launch.d.ts +12 -1
  26. package/dist/core/runtime/launch.js +18 -2
  27. package/dist/core/runtime/presence.d.ts +1 -1
  28. package/dist/core/runtime/presence.js +6 -4
  29. package/dist/core/runtime/promote.d.ts +4 -0
  30. package/dist/core/runtime/promote.js +21 -6
  31. package/dist/core/runtime/revive.js +6 -8
  32. package/dist/core/runtime/roadmap.d.ts +5 -4
  33. package/dist/core/runtime/roadmap.js +9 -16
  34. package/dist/core/runtime/spawn.d.ts +0 -2
  35. package/dist/core/runtime/spawn.js +26 -16
  36. package/dist/core/runtime/tmux.d.ts +18 -0
  37. package/dist/core/runtime/tmux.js +77 -0
  38. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  39. package/dist/pi-extensions/canvas-commands.js +100 -0
  40. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  41. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  42. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  43. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  44. package/dist/pi-extensions/canvas-stophook.js +42 -19
  45. 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, '&lt;')
63
+ .replace(/>/g, '&gt;')
64
+ .replace(/"/g, '&quot;');
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 — first nudge at 100k input tokens, then one
136
- // every 50k thereafter (150k, 200k, 250k, …). Unbounded: a long-lived node
137
- // keeps getting reminded as it grows.
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
- /** The highest band boundary at or below `tokens` (100k, 150k, 200k, …), or
142
- * null below the floor. */
143
- function steerBand(tokens) {
144
- if (tokens < STEER_FLOOR)
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
- return STEER_FLOOR + Math.floor((tokens - STEER_FLOOR) / STEER_STEP) * STEER_STEP;
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. An
149
- * orchestrator is steered to checkpoint its roadmap and yield; a non-
150
- * orchestrator (base worker) is steered to PROMOTE itself become a resident
151
- * orchestrator when work remains, or finish if it's nearly done. */
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
- return `Context ~${k}k and climbing. If more work remains than this context can finish, \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh). If you're nearly done, finish with \`crtr push final\`.`;
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 (100k, then every 50k). The nudge depends on the node's CURRENT mode,
183
- // read at fire time since a base worker can promote mid-session: an
184
- // orchestrator checkpoints + yields; a base worker is steered to promote.
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 at = steerBand(totalIn);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",