@teammates/cli 0.5.3 → 0.6.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/dist/adapter.d.ts CHANGED
@@ -29,6 +29,12 @@ export interface AgentAdapter {
29
29
  resumeSession?(teammate: TeammateConfig, sessionId: string): Promise<string>;
30
30
  /** Get the session file path for a teammate (if session is active). */
31
31
  getSessionFile?(teammateName: string): string | undefined;
32
+ /**
33
+ * Kill a running agent and return its partial output.
34
+ * Used by the interrupt-and-resume system to capture in-progress work.
35
+ * Returns null if no agent is running for this teammate.
36
+ */
37
+ killAgent?(teammate: string): Promise<import("./adapters/cli-proxy.js").SpawnResult | null>;
32
38
  /** Clean up a session. */
33
39
  destroySession?(sessionId: string): Promise<void>;
34
40
  /**
@@ -82,7 +88,7 @@ export declare function syncRecallIndex(teammatesDir: string, teammate?: string)
82
88
  * - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
83
89
  * - Weekly summaries are excluded (already indexed by recall)
84
90
  */
85
- export declare const DAILY_LOG_BUDGET_TOKENS = 24000;
91
+ export declare const DAILY_LOG_BUDGET_TOKENS = 12000;
86
92
  /**
87
93
  * Build the full prompt for a teammate session.
88
94
  * Includes identity, memory, roster, output protocol, and the task.
package/dist/adapter.js CHANGED
@@ -67,7 +67,7 @@ const CHARS_PER_TOKEN = 4;
67
67
  * - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
68
68
  * - Weekly summaries are excluded (already indexed by recall)
69
69
  */
70
- export const DAILY_LOG_BUDGET_TOKENS = 24_000;
70
+ export const DAILY_LOG_BUDGET_TOKENS = 12_000;
71
71
  const RECALL_MIN_BUDGET_TOKENS = 8_000;
72
72
  const RECALL_OVERFLOW_TOKENS = 4_000;
73
73
  /** Estimate tokens from character count. */
@@ -171,7 +171,14 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
171
171
  // ── Task-adjacent context (close to task for maximum relevance) ───
172
172
  // <RECALL_RESULTS> — budget-allocated, adjacent to task
173
173
  const recallBudget = Math.max(RECALL_MIN_BUDGET_TOKENS, RECALL_MIN_BUDGET_TOKENS + dailyBudget);
174
- const recallResults = options?.recallResults ?? [];
174
+ // Filter recall results that duplicate daily log content already in the prompt
175
+ const dailyLogDates = new Set(teammate.dailyLogs.slice(0, 7).map((log) => log.date));
176
+ const recallResults = (options?.recallResults ?? []).filter((r) => {
177
+ const dailyMatch = r.uri.match(/memory\/(\d{4}-\d{2}-\d{2})\.md/);
178
+ if (dailyMatch && dailyLogDates.has(dailyMatch[1]))
179
+ return false;
180
+ return true;
181
+ });
175
182
  if (recallResults.length > 0) {
176
183
  const lines = [
177
184
  "<RECALL_RESULTS>",
@@ -207,6 +214,8 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
207
214
  const instrLines = [
208
215
  "<INSTRUCTIONS>",
209
216
  "",
217
+ "**Your FIRST priority is answering the user's request in `<TASK>`. Session updates, memory writes, and continuity housekeeping are SECONDARY — do them AFTER producing your text response, not before.**",
218
+ "",
210
219
  "### Output Protocol (CRITICAL)",
211
220
  "",
212
221
  "**Your #1 job is to produce a visible text response.** Session updates and memory writes are secondary — they support continuity but are not the deliverable. The user sees ONLY your text output. If you update files but return no text, the user sees an empty message and your work is invisible.",
@@ -246,8 +255,12 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
246
255
  if (options?.sessionFile) {
247
256
  instrLines.push("", "### Session State", "", `Your session file is at: \`${options.sessionFile}\``, "", "**After completing the task**, append a brief entry to this file with:", "- What you did", "- Key decisions made", "- Files changed", "- Anything the next task should know", "", "This is how you maintain continuity across tasks. Always read it, always update it.");
248
257
  }
258
+ // Cross-folder write boundary (AI teammates only)
259
+ if (teammate.type === "ai") {
260
+ instrLines.push("", "### Folder Boundaries (ENFORCED)", "", `**You MUST NOT create, edit, or delete files inside another teammate's folder (\`.teammates/<other>/\`).** Your folder is \`.teammates/${teammate.name}/\` — you may only write inside it. Shared folders (\`.teammates/_*/\`) and ephemeral folders (\`.teammates/.*/\`) are also writable.`, "", "If your task requires changes to another teammate's files, you MUST hand off that work using the handoff block format above. Violation of this rule will cause your changes to be flagged and potentially reverted.");
261
+ }
249
262
  // Memory updates
250
- instrLines.push("", "### Memory Updates", "", "**After completing the task**, update your memory files:", "", `1. **Daily log** — Read \`.teammates/${teammate.name}/memory/${today}.md\` first (it may have entries from earlier tasks today), then write it back with your entry added. Create the file if it doesn't exist.`, " - What you did", " - Key decisions made", " - Files changed", " - Anything the next task should know", "", `2. **Typed memories** — If you learned something durable (a decision, pattern, feedback, or reference), create a typed memory file at \`.teammates/${teammate.name}/memory/<type>_<topic>.md\` with frontmatter (\`name\`, \`description\`, \`type\`). Update existing memory files if the topic already has one.`, "", "3. **WISDOM.md** — Do not edit directly. Wisdom entries are distilled from typed memories during compaction.", "", "These files are your persistent memory. Without them, your next session starts from scratch.");
263
+ instrLines.push("", "### Memory Updates", "", "**After completing the task**, update your memory files:", "", `1. **Daily log** — Read \`.teammates/${teammate.name}/memory/${today}.md\` first (it may have entries from earlier tasks today), then write it back with your entry added. Create the file if it doesn't exist. Always include YAML frontmatter with \`version: 0.6.0\` and \`type: daily\`.`, " - What you did", " - Key decisions made", " - Files changed", " - Anything the next task should know", "", `2. **Typed memories** — If you learned something durable (a decision, pattern, feedback, or reference), create a typed memory file at \`.teammates/${teammate.name}/memory/<type>_<topic>.md\` with frontmatter (\`version\`, \`name\`, \`description\`, \`type\`). Always include \`version: 0.6.0\` as the first field. Update existing memory files if the topic already has one.`, "", "3. **WISDOM.md** — Do not edit directly. Wisdom entries are distilled from typed memories during compaction.", "", "These files are your persistent memory. Without them, your next session starts from scratch.");
251
264
  // Section Reinforcement — back-references from high-attention bottom edge to each section tag
252
265
  instrLines.push("", "### Section Reinforcement", "");
253
266
  instrLines.push("- Stay in character as defined in `<IDENTITY>` — never break persona or speak as a generic assistant.");
@@ -273,7 +286,19 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
273
286
  if (options?.handoffContext) {
274
287
  instrLines.push("- When `<HANDOFF_CONTEXT>` is present, address its requirements and open questions directly.");
275
288
  }
276
- instrLines.push("- Your response must answer `<TASK>` — everything else is supporting context.", "", "**REMINDER: You MUST end your turn with visible text output. A turn with only file edits and no text is a failed turn.**");
289
+ instrLines.push("- Your response must answer `<TASK>` — everything else is supporting context.");
290
+ // Echo the user's actual request at the bottom edge for maximum attention.
291
+ // The orchestrator prepends conversation history before a "---" separator,
292
+ // so the user's raw message is the last segment after splitting on "---".
293
+ const segments = taskPrompt.split(/\n\n---\n\n/);
294
+ const userRequest = segments[segments.length - 1].trim();
295
+ if (userRequest.length > 0 && userRequest.length < 500) {
296
+ instrLines.push("", `**THE USER'S REQUEST:** ${userRequest}`);
297
+ }
298
+ else if (userRequest.length >= 500) {
299
+ instrLines.push("", "**IMPORTANT: The user's actual request is at the end of `<TASK>`. Read and address it before doing anything else.**");
300
+ }
301
+ instrLines.push("", "**REMINDER: You MUST end your turn with visible text output. A turn with only file edits and no text is a failed turn.**");
277
302
  parts.push(instrLines.join("\n"));
278
303
  return parts.join("\n");
279
304
  }
@@ -101,27 +101,24 @@ describe("buildTeammatePrompt", () => {
101
101
  expect(prompt).toContain("### Session State");
102
102
  expect(prompt).toContain("/tmp/beacon-session.md");
103
103
  });
104
- it("drops daily logs that exceed the 24k daily budget", () => {
105
- // Each log is ~50k chars = ~12.5k tokens. Only 1 fits in 24k daily budget.
104
+ it("drops daily logs that exceed the 12k daily budget", () => {
105
+ // Each log is ~50k chars = ~12.5k tokens. First one exceeds 12k budget, dropped.
106
106
  const bigContent = "D".repeat(50_000);
107
107
  const config = makeConfig({
108
108
  dailyLogs: [
109
109
  { date: "2026-03-18", content: "Today's log — never trimmed" },
110
- { date: "2026-03-17", content: bigContent }, // day 2 — fits in 24k
111
- { date: "2026-03-16", content: bigContent }, // day 3 — exceeds 24k, dropped
110
+ { date: "2026-03-17", content: bigContent }, // day 2 — exceeds 12k, dropped
112
111
  ],
113
112
  });
114
113
  const prompt = buildTeammatePrompt(config, "task");
115
114
  // Today's log is always fully present (never trimmed)
116
115
  expect(prompt).toContain("Today's log — never trimmed");
117
- // Day 2 fits within 24k
118
- expect(prompt).toContain("2026-03-17");
119
- // Day 3 doesn't fit (12.5k + 12.5k > 24k)
120
- expect(prompt).not.toContain("2026-03-16");
116
+ // Day 2 doesn't fit (12.5k > 12k)
117
+ expect(prompt).not.toContain("2026-03-17");
121
118
  });
122
- it("recall gets at least 8k tokens even when daily logs use full 24k", () => {
123
- // Daily logs fill their 24k budget. Recall still gets its guaranteed 8k minimum.
124
- const dailyContent = "D".repeat(90_000); // ~22.5k tokens — fits in 24k
119
+ it("recall gets at least 8k tokens even when daily logs use full 12k", () => {
120
+ // Daily logs fill their 12k budget. Recall still gets its guaranteed 8k minimum.
121
+ const dailyContent = "D".repeat(40_000); // ~10k tokens — fits in 12k
125
122
  const config = makeConfig({
126
123
  dailyLogs: [
127
124
  { date: "2026-03-18", content: "today" },
@@ -144,7 +141,7 @@ describe("buildTeammatePrompt", () => {
144
141
  expect(prompt).toContain("<RECALL_RESULTS>");
145
142
  });
146
143
  it("recall gets unused daily log budget", () => {
147
- // Small daily logs leave most of 24k unused — recall gets the surplus.
144
+ // Small daily logs leave most of 12k unused — recall gets the surplus.
148
145
  const config = makeConfig({
149
146
  dailyLogs: [
150
147
  { date: "2026-03-18", content: "today" },
@@ -152,7 +149,7 @@ describe("buildTeammatePrompt", () => {
152
149
  ],
153
150
  });
154
151
  // Large recall result — should fit because daily logs barely used any budget
155
- const recallText = "R".repeat(80_000); // ~20k tokens — fits in (8k + ~24k unused)
152
+ const recallText = "R".repeat(80_000); // ~20k tokens — fits in (8k + ~12k unused)
156
153
  const prompt = buildTeammatePrompt(config, "task", {
157
154
  recallResults: [
158
155
  {
@@ -86,6 +86,8 @@ export declare class CliProxyAdapter implements AgentAdapter {
86
86
  private sessionsDir;
87
87
  /** Temp prompt files that need cleanup — guards against crashes before finally. */
88
88
  private pendingTempFiles;
89
+ /** Active child processes per teammate — used by killAgent() for interruption. */
90
+ private activeProcesses;
89
91
  constructor(options: CliProxyOptions);
90
92
  startSession(teammate: TeammateConfig): Promise<string>;
91
93
  executeTask(_sessionId: string, teammate: TeammateConfig, prompt: string, options?: {
@@ -93,6 +95,7 @@ export declare class CliProxyAdapter implements AgentAdapter {
93
95
  }): Promise<TaskResult>;
94
96
  routeTask(task: string, roster: RosterEntry[]): Promise<string | null>;
95
97
  getSessionFile(teammateName: string): string | undefined;
98
+ killAgent(teammate: string): Promise<SpawnResult | null>;
96
99
  destroySession(_sessionId: string): Promise<void>;
97
100
  /**
98
101
  * Spawn the agent, stream its output live, and capture it.
@@ -20,7 +20,7 @@ import { mkdirSync } from "node:fs";
20
20
  import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
21
21
  import { tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
- import { DAILY_LOG_BUDGET_TOKENS, buildTeammatePrompt, queryRecallContext, } from "../adapter.js";
23
+ import { buildTeammatePrompt, DAILY_LOG_BUDGET_TOKENS, queryRecallContext, } from "../adapter.js";
24
24
  import { autoCompactForBudget } from "../compact.js";
25
25
  export const PRESETS = {
26
26
  claude: {
@@ -103,6 +103,8 @@ export class CliProxyAdapter {
103
103
  sessionsDir = "";
104
104
  /** Temp prompt files that need cleanup — guards against crashes before finally. */
105
105
  pendingTempFiles = new Set();
106
+ /** Active child processes per teammate — used by killAgent() for interruption. */
107
+ activeProcesses = new Map();
106
108
  constructor(options) {
107
109
  this.options = options;
108
110
  this.preset =
@@ -321,6 +323,20 @@ export class CliProxyAdapter {
321
323
  getSessionFile(teammateName) {
322
324
  return this.sessionFiles.get(teammateName);
323
325
  }
326
+ async killAgent(teammate) {
327
+ const entry = this.activeProcesses.get(teammate);
328
+ if (!entry || entry.child.killed)
329
+ return null;
330
+ // Kill with SIGTERM → 5s → SIGKILL
331
+ entry.child.kill("SIGTERM");
332
+ setTimeout(() => {
333
+ if (!entry.child.killed) {
334
+ entry.child.kill("SIGKILL");
335
+ }
336
+ }, 5_000);
337
+ // Wait for the process to exit — the spawnAndProxy close handler resolves this
338
+ return entry.done;
339
+ }
324
340
  async destroySession(_sessionId) {
325
341
  // Clean up any leaked temp prompt files
326
342
  for (const file of this.pendingTempFiles) {
@@ -337,119 +353,128 @@ export class CliProxyAdapter {
337
353
  * Spawn the agent, stream its output live, and capture it.
338
354
  */
339
355
  spawnAndProxy(teammate, promptFile, fullPrompt) {
340
- return new Promise((resolve, reject) => {
341
- // Always generate a debug log file for presets that support it (e.g. Claude's --debug-file).
342
- // Written to .teammates/.tmp/debug/ so startup maintenance can clean old logs.
343
- let debugFile;
344
- if (this.preset.supportsDebugFile) {
345
- const debugDir = join(teammate.cwd ?? process.cwd(), ".teammates", ".tmp", "debug");
346
- try {
347
- mkdirSync(debugDir, { recursive: true });
348
- }
349
- catch {
350
- /* best effort */
351
- }
352
- debugFile = join(debugDir, `agent-${teammate.name}-${Date.now()}.log`);
353
- }
354
- const args = [
355
- ...this.preset.buildArgs({ promptFile, prompt: fullPrompt, debugFile }, teammate, this.options),
356
- ...(this.options.extraFlags ?? []),
357
- ];
358
- const command = this.options.commandPath ?? this.preset.command;
359
- const env = { ...process.env, ...this.preset.env };
360
- const timeout = this.options.timeout ?? 600_000;
361
- const interactive = this.preset.interactive ?? false;
362
- const useStdin = this.preset.stdinPrompt ?? false;
363
- // Suppress Node.js ExperimentalWarning (e.g. SQLite) in agent
364
- // subprocesses so it doesn't leak into the terminal UI.
365
- const existingNodeOpts = env.NODE_OPTIONS ?? "";
366
- if (!existingNodeOpts.includes("--disable-warning=ExperimentalWarning")) {
367
- env.NODE_OPTIONS = existingNodeOpts
368
- ? `${existingNodeOpts} --disable-warning=ExperimentalWarning`
369
- : "--disable-warning=ExperimentalWarning";
356
+ // Create a deferred promise so killAgent() can await the same result
357
+ let resolveOuter;
358
+ let rejectOuter;
359
+ const done = new Promise((res, rej) => {
360
+ resolveOuter = res;
361
+ rejectOuter = rej;
362
+ });
363
+ // Always generate a debug log file for presets that support it (e.g. Claude's --debug-file).
364
+ // Written to .teammates/.tmp/debug/ so startup maintenance can clean old logs.
365
+ let debugFile;
366
+ if (this.preset.supportsDebugFile) {
367
+ const debugDir = join(teammate.cwd ?? process.cwd(), ".teammates", ".tmp", "debug");
368
+ try {
369
+ mkdirSync(debugDir, { recursive: true });
370
370
  }
371
- // On Windows, npm-installed CLIs are .cmd wrappers that require shell.
372
- // When using shell mode, pass command+args as a single string to avoid
373
- // Node DEP0190 deprecation warning about unescaped args with shell: true.
374
- const needsShell = this.preset.shell ?? process.platform === "win32";
375
- const spawnCmd = needsShell ? [command, ...args].join(" ") : command;
376
- const spawnArgs = needsShell ? [] : args;
377
- const child = spawn(spawnCmd, spawnArgs, {
378
- cwd: teammate.cwd ?? process.cwd(),
379
- env,
380
- stdio: [interactive || useStdin ? "pipe" : "ignore", "pipe", "pipe"],
381
- shell: needsShell,
382
- });
383
- // Pipe prompt via stdin if the preset requires it
384
- if (useStdin && child.stdin) {
385
- child.stdin.write(fullPrompt);
386
- child.stdin.end();
371
+ catch {
372
+ /* best effort */
387
373
  }
388
- // ── Timeout with SIGTERM → SIGKILL escalation ──────────────
389
- let killed = false;
390
- let killTimer = null;
391
- const timeoutTimer = setTimeout(() => {
392
- if (!child.killed) {
393
- killed = true;
394
- child.kill("SIGTERM");
395
- // If SIGTERM doesn't work after 5s, force-kill
396
- killTimer = setTimeout(() => {
397
- if (!child.killed) {
398
- child.kill("SIGKILL");
399
- }
400
- }, 5_000);
401
- }
402
- }, timeout);
403
- // Connect user's stdin → child only if agent may ask questions
404
- let onUserInput = null;
405
- if (interactive && !useStdin && child.stdin) {
406
- onUserInput = (chunk) => {
407
- child.stdin?.write(chunk);
408
- };
409
- if (process.stdin.isTTY) {
410
- process.stdin.setRawMode(false);
411
- }
412
- process.stdin.resume();
413
- process.stdin.on("data", onUserInput);
374
+ debugFile = join(debugDir, `agent-${teammate.name}-${Date.now()}.log`);
375
+ }
376
+ const args = [
377
+ ...this.preset.buildArgs({ promptFile, prompt: fullPrompt, debugFile }, teammate, this.options),
378
+ ...(this.options.extraFlags ?? []),
379
+ ];
380
+ const command = this.options.commandPath ?? this.preset.command;
381
+ const env = { ...process.env, ...this.preset.env };
382
+ const timeout = this.options.timeout ?? 600_000;
383
+ const interactive = this.preset.interactive ?? false;
384
+ const useStdin = this.preset.stdinPrompt ?? false;
385
+ // Suppress Node.js ExperimentalWarning (e.g. SQLite) in agent
386
+ // subprocesses so it doesn't leak into the terminal UI.
387
+ const existingNodeOpts = env.NODE_OPTIONS ?? "";
388
+ if (!existingNodeOpts.includes("--disable-warning=ExperimentalWarning")) {
389
+ env.NODE_OPTIONS = existingNodeOpts
390
+ ? `${existingNodeOpts} --disable-warning=ExperimentalWarning`
391
+ : "--disable-warning=ExperimentalWarning";
392
+ }
393
+ // On Windows, npm-installed CLIs are .cmd wrappers that require shell.
394
+ // When using shell mode, pass command+args as a single string to avoid
395
+ // Node DEP0190 deprecation warning about unescaped args with shell: true.
396
+ const needsShell = this.preset.shell ?? process.platform === "win32";
397
+ const spawnCmd = needsShell ? [command, ...args].join(" ") : command;
398
+ const spawnArgs = needsShell ? [] : args;
399
+ const child = spawn(spawnCmd, spawnArgs, {
400
+ cwd: teammate.cwd ?? process.cwd(),
401
+ env,
402
+ stdio: [interactive || useStdin ? "pipe" : "ignore", "pipe", "pipe"],
403
+ shell: needsShell,
404
+ });
405
+ // Register the active process for killAgent() access
406
+ this.activeProcesses.set(teammate.name, { child, done, debugFile });
407
+ // Pipe prompt via stdin if the preset requires it
408
+ if (useStdin && child.stdin) {
409
+ child.stdin.write(fullPrompt);
410
+ child.stdin.end();
411
+ }
412
+ // ── Timeout with SIGTERM → SIGKILL escalation ──────────────
413
+ let killed = false;
414
+ let killTimer = null;
415
+ const timeoutTimer = setTimeout(() => {
416
+ if (!child.killed) {
417
+ killed = true;
418
+ child.kill("SIGTERM");
419
+ // If SIGTERM doesn't work after 5s, force-kill
420
+ killTimer = setTimeout(() => {
421
+ if (!child.killed) {
422
+ child.kill("SIGKILL");
423
+ }
424
+ }, 5_000);
414
425
  }
415
- const stdoutBufs = [];
416
- const stderrBufs = [];
417
- child.stdout?.on("data", (chunk) => {
418
- stdoutBufs.push(chunk);
419
- });
420
- child.stderr?.on("data", (chunk) => {
421
- stderrBufs.push(chunk);
422
- });
423
- const cleanup = () => {
424
- clearTimeout(timeoutTimer);
425
- if (killTimer)
426
- clearTimeout(killTimer);
427
- if (onUserInput) {
428
- process.stdin.removeListener("data", onUserInput);
429
- }
426
+ }, timeout);
427
+ // Connect user's stdin → child only if agent may ask questions
428
+ let onUserInput = null;
429
+ if (interactive && !useStdin && child.stdin) {
430
+ onUserInput = (chunk) => {
431
+ child.stdin?.write(chunk);
430
432
  };
431
- child.on("close", (code, signal) => {
432
- cleanup();
433
- const stdout = Buffer.concat(stdoutBufs).toString("utf-8");
434
- const stderr = Buffer.concat(stderrBufs).toString("utf-8");
435
- const output = stdout + (stderr ? `\n${stderr}` : "");
436
- resolve({
437
- output: killed
438
- ? `${output}\n\n[TIMEOUT] Agent process killed after ${timeout}ms`
439
- : output,
440
- stdout,
441
- stderr,
442
- exitCode: code,
443
- signal: signal ?? null,
444
- timedOut: killed,
445
- debugFile,
446
- });
447
- });
448
- child.on("error", (err) => {
449
- cleanup();
450
- reject(new Error(`Failed to spawn ${command}: ${err.message}`));
433
+ if (process.stdin.isTTY) {
434
+ process.stdin.setRawMode(false);
435
+ }
436
+ process.stdin.resume();
437
+ process.stdin.on("data", onUserInput);
438
+ }
439
+ const stdoutBufs = [];
440
+ const stderrBufs = [];
441
+ child.stdout?.on("data", (chunk) => {
442
+ stdoutBufs.push(chunk);
443
+ });
444
+ child.stderr?.on("data", (chunk) => {
445
+ stderrBufs.push(chunk);
446
+ });
447
+ const cleanup = () => {
448
+ clearTimeout(timeoutTimer);
449
+ if (killTimer)
450
+ clearTimeout(killTimer);
451
+ if (onUserInput) {
452
+ process.stdin.removeListener("data", onUserInput);
453
+ }
454
+ this.activeProcesses.delete(teammate.name);
455
+ };
456
+ child.on("close", (code, signal) => {
457
+ cleanup();
458
+ const stdout = Buffer.concat(stdoutBufs).toString("utf-8");
459
+ const stderr = Buffer.concat(stderrBufs).toString("utf-8");
460
+ const output = stdout + (stderr ? `\n${stderr}` : "");
461
+ resolveOuter({
462
+ output: killed
463
+ ? `${output}\n\n[TIMEOUT] Agent process killed after ${timeout}ms`
464
+ : output,
465
+ stdout,
466
+ stderr,
467
+ exitCode: code,
468
+ signal: signal ?? null,
469
+ timedOut: killed,
470
+ debugFile,
451
471
  });
452
472
  });
473
+ child.on("error", (err) => {
474
+ cleanup();
475
+ rejectOuter(new Error(`Failed to spawn ${command}: ${err.message}`));
476
+ });
477
+ return done;
453
478
  }
454
479
  }
455
480
  // ─── Output parsing (shared across all agents) ─────────────────────
@@ -46,5 +46,11 @@ export declare function findSummarizationSplit(history: ConversationEntry[], bud
46
46
  * Build the summarization prompt text from entries being pushed out of the budget.
47
47
  */
48
48
  export declare function buildSummarizationPrompt(entries: ConversationEntry[], existingSummary: string): string;
49
+ /**
50
+ * Mechanically compress conversation entries into a condensed bullet summary.
51
+ * Used for fast pre-dispatch compression when history exceeds the token budget.
52
+ * Each entry becomes a one-line bullet with truncated text.
53
+ */
54
+ export declare function compressConversationEntries(entries: ConversationEntry[], existingSummary: string): string;
49
55
  /** Check if a string looks like an image file path. */
50
56
  export declare function isImagePath(text: string): boolean;
package/dist/cli-utils.js CHANGED
@@ -135,6 +135,23 @@ export function buildSummarizationPrompt(entries, existingSummary) {
135
135
  ? `You are maintaining a running summary of an ongoing conversation between a user and their AI teammates. Update the existing summary to incorporate the new conversation entries below.\n\n## Current Summary\n\n${existingSummary}\n\n## New Entries to Incorporate\n\n${entriesText}\n\n${instructions}`
136
136
  : `You are maintaining a running summary of an ongoing conversation between a user and their AI teammates. Summarize the conversation entries below.\n\n## Entries to Summarize\n\n${entriesText}\n\n${instructions}`;
137
137
  }
138
+ /**
139
+ * Mechanically compress conversation entries into a condensed bullet summary.
140
+ * Used for fast pre-dispatch compression when history exceeds the token budget.
141
+ * Each entry becomes a one-line bullet with truncated text.
142
+ */
143
+ export function compressConversationEntries(entries, existingSummary) {
144
+ const bullets = entries.map((e) => {
145
+ const firstLine = e.text.split("\n")[0].slice(0, 150);
146
+ const ellipsis = e.text.length > 150 || e.text.includes("\n") ? "…" : "";
147
+ return `- **${e.role}:** ${firstLine}${ellipsis}`;
148
+ });
149
+ const compressed = bullets.join("\n");
150
+ if (existingSummary) {
151
+ return `${existingSummary}\n\n### Compressed\n${compressed}`;
152
+ }
153
+ return compressed;
154
+ }
138
155
  /** Check if a string looks like an image file path. */
139
156
  export function isImagePath(text) {
140
157
  // Must look like a file path (contains slash or backslash, or starts with drive letter)
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { buildConversationContext, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, formatConversationEntry, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
2
+ import { buildConversationContext, buildSummarizationPrompt, compressConversationEntries, cleanResponseBody, findAtMention, findSummarizationSplit, formatConversationEntry, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
3
3
  // ── relativeTime ────────────────────────────────────────────────────
4
4
  describe("relativeTime", () => {
5
5
  beforeEach(() => {
@@ -361,3 +361,52 @@ describe("buildSummarizationPrompt", () => {
361
361
  expect(prompt).toContain("**scribe:**\nLine 1\nLine 2");
362
362
  });
363
363
  });
364
+ // ── compressConversationEntries ──────────────────────────────────────
365
+ describe("compressConversationEntries", () => {
366
+ it("compresses entries into bullet summaries", () => {
367
+ const entries = [
368
+ { role: "stevenic", text: "Build the feature" },
369
+ { role: "beacon", text: "Done, here's what I did" },
370
+ ];
371
+ const result = compressConversationEntries(entries, "");
372
+ expect(result).toContain("- **stevenic:** Build the feature");
373
+ expect(result).toContain("- **beacon:** Done, here's what I did");
374
+ });
375
+ it("truncates long text at 150 chars with ellipsis", () => {
376
+ const entries = [
377
+ { role: "scribe", text: "A".repeat(200) },
378
+ ];
379
+ const result = compressConversationEntries(entries, "");
380
+ expect(result).toContain("A".repeat(150));
381
+ expect(result).toContain("…");
382
+ expect(result).not.toContain("A".repeat(151));
383
+ });
384
+ it("adds ellipsis for multi-line text even if short", () => {
385
+ const entries = [
386
+ { role: "beacon", text: "Line 1\nLine 2" },
387
+ ];
388
+ const result = compressConversationEntries(entries, "");
389
+ expect(result).toContain("- **beacon:** Line 1…");
390
+ });
391
+ it("prepends existing summary with compressed section", () => {
392
+ const entries = [
393
+ { role: "stevenic", text: "Do the thing" },
394
+ ];
395
+ const result = compressConversationEntries(entries, "Earlier context here");
396
+ expect(result).toContain("Earlier context here");
397
+ expect(result).toContain("### Compressed");
398
+ expect(result).toContain("- **stevenic:** Do the thing");
399
+ });
400
+ it("returns plain bullets when no existing summary", () => {
401
+ const entries = [
402
+ { role: "user", text: "Hello" },
403
+ ];
404
+ const result = compressConversationEntries(entries, "");
405
+ expect(result).not.toContain("### Compressed");
406
+ expect(result).toBe("- **user:** Hello");
407
+ });
408
+ it("handles empty entries array", () => {
409
+ const result = compressConversationEntries([], "");
410
+ expect(result).toBe("");
411
+ });
412
+ });