castle-web-cli 0.4.108 → 0.4.109

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.
@@ -6,6 +6,20 @@
6
6
  // Router calls are stateless (a fresh cursor-agent print run each time) so the
7
7
  // transcript is the only memory.
8
8
  const TRANSCRIPT_LIMIT = 40;
9
+ // Byte ceiling on the replayed transcript, applied AFTER TRANSCRIPT_LIMIT.
10
+ // The count cap above bounds how many messages replay, not how big they are --
11
+ // forty long ones clear 128KB easily. That matters because the whole prompt is
12
+ // handed to the backend CLI as a single argv entry, and Linux caps one argument
13
+ // at MAX_ARG_STRLEN (128KB): past it, spawn() throws E2BIG and the turn dies
14
+ // before it starts. Since the transcript is the only term here that grows
15
+ // without bound (it accumulates for the life of the deck), it's the one that
16
+ // needs a byte budget.
17
+ //
18
+ // Sized to leave room for everything else the prompt carries: the rules
19
+ // (~10KB), the deck's quick reference (~13KB), the file tree, the smith-only
20
+ // deck contents (ROUTER_DECK_CONTENTS_BUDGET, 40KB), the task board, and this
21
+ // turn's instruction.
22
+ const TRANSCRIPT_BYTE_BUDGET = 32 * 1024;
9
23
  const ROUTER_RULES = `You are Castle's create assistant: the fast conversational router for a game-making session. The deck (game project) lives in the current directory and runs live in a pane right next to this chat.
10
24
 
11
25
  What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's Quick reference and file list below describe its setup; the full CLAUDE.md / AGENTS.md has deeper detail. NEVER claim something is impossible or unsupported on the platform without checking that context (or, for specifics it doesn't cover, the deck's files) first.
@@ -66,20 +80,55 @@ Conversation style:
66
80
  - The user sees a live task board above the chat -- never re-announce task status yourself.
67
81
  - Spawning a task does NOT apply the change -- tasks run for minutes and finish on the board. Talk about spawned work in future tense ("this will dial the shake back"), and NEVER ask how a change feels right after spawning it -- the user cannot have tried it yet. Save "how is it?" for things whose task already finished.
68
82
  - The user playtests in the pane beside this chat; finished work shows up there after a reload.`;
83
+ function renderTranscriptLine(m) {
84
+ if (m.role === "user")
85
+ return `user: ${m.text}`;
86
+ const label = m.interrupted
87
+ ? "you (interrupted draft -- not a complete reply)"
88
+ : "you";
89
+ return `${label}: ${m.text}`;
90
+ }
91
+ // Cut a line to fit `budget` BYTES without splitting a multi-byte character
92
+ // (a half-written character would render as a replacement glyph mid-sentence).
93
+ function truncateToBytes(line, budget) {
94
+ const buf = Buffer.from(line, "utf8");
95
+ if (buf.byteLength <= budget)
96
+ return line;
97
+ return new TextDecoder("utf8", { fatal: false, ignoreBOM: true })
98
+ .decode(buf.subarray(0, budget))
99
+ .replace(/�+$/, "");
100
+ }
69
101
  function renderTranscript(messages) {
70
102
  const recent = messages.slice(-TRANSCRIPT_LIMIT);
71
103
  if (recent.length === 0)
72
104
  return "(no conversation yet)";
73
- return recent
74
- .map((m) => {
75
- if (m.role === "user")
76
- return `user: ${m.text}`;
77
- const label = m.interrupted
78
- ? "you (interrupted draft -- not a complete reply)"
79
- : "you";
80
- return `${label}: ${m.text}`;
81
- })
82
- .join("\n\n");
105
+ // Newest-first, keeping WHOLE messages until the budget is spent: the recent
106
+ // exchanges are the ones a reply actually depends on, and half-including a
107
+ // message would read as the user having said something they didn't.
108
+ const kept = [];
109
+ let bytes = 0;
110
+ for (let i = recent.length - 1; i >= 0; i -= 1) {
111
+ const line = renderTranscriptLine(recent[i]);
112
+ // +2 for the "\n\n" this line will be joined with.
113
+ const size = Buffer.byteLength(line, "utf8") + 2;
114
+ if (bytes + size > TRANSCRIPT_BYTE_BUDGET) {
115
+ // One message bigger than the whole budget (a huge paste) still has to
116
+ // yield something -- an empty transcript would strand the router with no
117
+ // idea what was just asked. Truncate that one rather than drop it, so the
118
+ // bound genuinely holds no matter what a single message contains.
119
+ if (kept.length === 0) {
120
+ kept.push(truncateToBytes(line, TRANSCRIPT_BYTE_BUDGET));
121
+ }
122
+ break;
123
+ }
124
+ kept.unshift(line);
125
+ bytes += size;
126
+ }
127
+ const elided = recent.length - kept.length;
128
+ if (elided <= 0)
129
+ return kept.join("\n\n");
130
+ const plural = elided === 1 ? "message" : "messages";
131
+ return `(${elided} earlier ${plural} trimmed to keep this prompt within its size limit)\n\n${kept.join("\n\n")}`;
83
132
  }
84
133
  function renderTasks(tasks) {
85
134
  if (tasks.length === 0)
package/dist/agent.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // Backend CLI: cursor-agent in headless print mode (stream-json). The router
15
15
  // runs with --mode ask (read-only at the CLI level); task agents run with
16
16
  // --force. Claude support can slot in later behind runAgentCli.
17
- import { execFileSync, spawn } from "child_process";
17
+ import { execFileSync, spawn, } from "child_process";
18
18
  import * as fs from "fs";
19
19
  import * as os from "os";
20
20
  import * as path from "path";
@@ -1341,11 +1341,33 @@ function makeAgentEventHandler(opts, state) {
1341
1341
  // end with a result event carrying the canonical final text.
1342
1342
  function runAgentCli(opts) {
1343
1343
  return new Promise((resolve) => {
1344
- const child = spawn(opts.command, opts.args, {
1345
- cwd: opts.cwd,
1346
- env: opts.env,
1347
- stdio: ["ignore", "pipe", "pipe"],
1348
- });
1344
+ // spawn() throws SYNCHRONOUSLY for the failures the OS rejects at exec
1345
+ // time -- in practice E2BIG, when the prompt argv exceeds Linux's
1346
+ // MAX_ARG_STRLEN (128KB per argument). That throw escapes this executor
1347
+ // and rejects the promise, so it never reaches the child.on("error")
1348
+ // handler below and never becomes an AgentFailure: the turn dies
1349
+ // unclassified and the composer spins forever with nothing shown. Settle
1350
+ // it here in the same shape that handler uses so it lands on the normal
1351
+ // "spawn" copy instead. The declared type mirrors the stdio tuple below:
1352
+ // stdin ignored, stdout/stderr piped.
1353
+ let child;
1354
+ try {
1355
+ child = spawn(opts.command, opts.args, {
1356
+ cwd: opts.cwd,
1357
+ env: opts.env,
1358
+ stdio: ["ignore", "pipe", "pipe"],
1359
+ });
1360
+ }
1361
+ catch (err) {
1362
+ const message = err instanceof Error ? err.message : String(err);
1363
+ resolve({
1364
+ ok: false,
1365
+ finalText: "",
1366
+ error: `could not run ${opts.command}: ${message}`,
1367
+ failure: { kind: "spawn", detail: `${opts.command}: ${message}` },
1368
+ });
1369
+ return;
1370
+ }
1349
1371
  opts.children.add(child);
1350
1372
  opts.onSpawn?.(child.pid);
1351
1373
  const log = opts.logPath