@teammates/cli 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -7,18 +7,17 @@ Agent-agnostic CLI orchestrator for teammates. Routes tasks to teammates, manage
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- cd cli
10
+ cd packages/cli
11
11
  npm install
12
12
  npm run build
13
13
  ```
14
14
 
15
- Then launch a session with your preferred agent:
15
+ Then launch a session:
16
16
 
17
17
  ```bash
18
- teammates claude # Claude Code
19
- teammates codex # OpenAI Codex
20
- teammates aider # Aider
21
- teammates echo # Test adapter (no external agent)
18
+ teammates # Uses default adapter (claude)
19
+ teammates --model sonnet # Override the agent model
20
+ teammates echo # Use the test adapter (no external agent)
22
21
  ```
23
22
 
24
23
  The CLI auto-discovers your `.teammates/` directory by walking up from the current working directory.
@@ -26,7 +25,7 @@ The CLI auto-discovers your `.teammates/` directory by walking up from the curre
26
25
  ## Usage
27
26
 
28
27
  ```
29
- teammates <agent> [options] [-- agent-flags...]
28
+ teammates [options] [-- agent-flags...]
30
29
  ```
31
30
 
32
31
  ### Options
@@ -37,7 +36,7 @@ teammates <agent> [options] [-- agent-flags...]
37
36
  | `--dir <path>` | Override `.teammates/` directory location |
38
37
  | `--help` | Show usage information |
39
38
 
40
- Any arguments after the agent name are passed through to the underlying agent CLI.
39
+ Any arguments after `--` are passed through to the underlying agent CLI.
41
40
 
42
41
  ## In-Session Commands
43
42
 
@@ -55,16 +54,18 @@ Once inside the REPL, you can interact with teammates using `@mentions`, `/comma
55
54
 
56
55
  | Command | Aliases | Description |
57
56
  |---|---|---|
58
- | `/status` | `/s`, `/queue`, `/qu` | Show teammates, active tasks, and queue |
59
- | `/log [teammate]` | `/l` | Show the last task result (optionally for a specific teammate) |
60
- | `/debug [teammate]` | `/raw` | Show raw agent output from the last task |
61
- | `/cancel <n>` | | Cancel a queued task by number |
62
- | `/init` | `/onboard`, `/setup` | Run onboarding to set up teammates for this project |
63
- | `/install <service>` | | Install a teammates service (e.g. `recall`) |
57
+ | `/status` | `/s`, `/queue` | Show teammates, active tasks, and queue |
58
+ | `/debug [teammate] [focus]` | | Analyze the last agent task with optional focus text |
59
+ | `/cancel [n]` | | Cancel a queued task by number |
60
+ | `/init` | `/onboard`, `/setup` | Run onboarding to set up teammates |
61
+ | `/init pick` | | Pick teammates from persona templates (in-TUI) |
64
62
  | `/compact [teammate]` | | Compact daily logs into weekly/monthly summaries |
65
63
  | `/retro [teammate]` | | Run a structured self-retrospective for a teammate |
64
+ | `/user [change]` | | View or update USER.md |
65
+ | `/btw [question]` | | Ask a quick side question without interrupting |
66
66
  | `/copy` | `/cp` | Copy the last response to clipboard |
67
67
  | `/theme` | | Show current theme colors |
68
+ | `/configure [service]` | `/config` | Configure external services (e.g. GitHub) |
68
69
  | `/clear` | `/cls`, `/reset` | Clear history and reset the session |
69
70
  | `/help` | `/h`, `/?` | Show available commands |
70
71
  | `/exit` | `/q`, `/quit` | Exit the session |
@@ -121,21 +122,24 @@ The CLI uses a generic adapter interface to support any coding agent. Each adapt
121
122
 
122
123
  | Preset | Command | Notes |
123
124
  |---|---|---|
124
- | `claude` | `claude -p --verbose` | Requires `claude` on PATH |
125
+ | `claude` | `claude -p --verbose` | Default adapter. Requires `claude` on PATH |
125
126
  | `codex` | `codex exec` | Requires `codex` on PATH |
126
127
  | `aider` | `aider --message-file` | Requires `aider` on PATH |
128
+ | `copilot` | GitHub Copilot SDK | Requires `@anthropic-ai/copilot-sdk` |
127
129
  | `echo` | (in-process) | Test adapter — echoes prompts, no external agent |
128
130
 
129
131
  ### How Adapters Work
130
132
 
131
- 1. The adapter queries the recall index for relevant memories (automatic, in-process)
132
- 2. The orchestrator builds a full prompt within a 32k token budget (SOUL WISDOMrecall results daily logs (budget-trimmed) session state roster task)
133
- 3. The prompt is written to a temp file
134
- 4. The agent CLI is spawned with the prompt
135
- 5. stdout/stderr are captured for result parsing
136
- 6. The output is parsed for embedded handoff blocks
137
- 7. The recall index is synced to pick up any files the agent created/modified
138
- 8. Temp files are cleaned up
133
+ 1. **Auto-compaction** If daily logs exceed the 24k token budget, oldest weeks are compacted into weekly summaries
134
+ 2. **Two-pass recall** Pass 1: keyword extraction query variation generationfrontmatter catalog matching multi-query fusion with dedup. Pass 2: agents can search mid-task via `teammates-recall search`
135
+ 3. The orchestrator builds a full prompt with token-budgeted sections (identity wisdom → recall → daily logs → roster → services → date/time/environment → user profile → task → output protocol → session/memory instructions)
136
+ 4. The prompt is written to a temp file
137
+ 5. The agent CLI is spawned with the prompt
138
+ 6. stdout/stderr are captured for result parsing
139
+ 7. **Empty response defense** If the agent returns no text: retry with raw mode (no prompt wrapping), then minimal prompt, then synthetic fallback from metadata
140
+ 8. The output is parsed for embedded handoff blocks (with natural-language fallback)
141
+ 9. The recall index is synced to pick up any files the agent created/modified
142
+ 10. Temp files are cleaned up
139
143
 
140
144
  ### Writing a Custom Adapter
141
145
 
@@ -169,13 +173,15 @@ Or add a preset to `cli-proxy.ts` for any CLI agent that accepts a prompt and ru
169
173
  The CLI startup runs in two phases:
170
174
 
171
175
  **Phase 1 — Pre-TUI (console I/O)**
172
- 1. **User profile setup** — Prompts for alias (required), name, role, experience, preferences, context. Creates `USER.md` and a user avatar folder at `.teammates/<alias>/` with `SOUL.md` (`**Type:** human`).
173
- 2. **Team onboarding** (if no `.teammates/` exists) — Offers New team / Import / Solo / Exit. Onboarding agents run non-interactively to completion.
176
+ 1. **User profile setup** — If `USER.md` is missing, offers three paths: **GitHub** (imports name/alias via `gh api user`), **Manual** (prompts for alias, name, role, experience, preferences), or **Skip**. Creates `USER.md` and a user avatar folder at `.teammates/<alias>/` with `SOUL.md` (`**Type:** human`). Auto-detects the user's timezone.
177
+ 2. **Team onboarding** (if `.teammates/` was just created) — Offers **Pick teammates** (persona templates), **Auto-generate** (agent-driven), **Import** (from another project), **Solo mode**, or **Exit**.
174
178
  3. **Orchestrator init** — Loads existing teammates from `.teammates/`, registers user avatar with `type: "human"` and `presence: "online"`.
179
+ 4. **Startup maintenance** — Runs auto-compaction and recall sync for all teammates (silent — progress bar only, no feed output unless actual work was done).
175
180
 
176
181
  **Phase 2 — TUI (Consolonia)**
177
- 4. Animated startup banner with roster
178
- 5. REPL starts — routing, slash commands, handoff approval
182
+ 5. Animated startup banner with presence-colored roster
183
+ 6. REPL starts — routing, slash commands, handoff approval
184
+ 7. System tasks (compaction, summarization, wisdom distillation) run in the background without blocking user tasks
179
185
 
180
186
  All user interaction during Phase 1 uses plain console I/O (readline + ora spinners), avoiding mouse tracking issues that would occur inside the TUI.
181
187
 
@@ -191,7 +197,7 @@ The CLI ships with 15 built-in persona templates that serve as starting points w
191
197
  | **2 — Specialist** | Security (`shield`), Designer (`canvas`), Tech Writer (`quill`), Data Engineer (`forge`), SRE (`watchtower`), Architect (`blueprint`) |
192
198
  | **3 — Niche** | Frontend (`pixel`), Backend (`engine`), Mobile (`orbit`), ML/AI (`neuron`), Performance (`tempo`) |
193
199
 
194
- During onboarding, the CLI uses these personas to scaffold teammates. The user picks roles, optionally renames them, and the persona's SOUL.md body becomes the starting template — project-specific sections (commands, file patterns, technologies) are filled in by the onboarding agent.
200
+ During onboarding, the CLI uses these personas to scaffold teammates. Use **Pick teammates** during initial onboarding or `/init pick` in-session to choose from the list. The user picks roles, optionally renames them, and the persona's SOUL.md body becomes the starting template — project-specific sections (commands, file patterns, technologies) are filled in by the onboarding agent.
195
201
 
196
202
  ## Architecture
197
203
 
@@ -201,12 +207,18 @@ cli/src/
201
207
  orchestrator.ts # Task routing, session management, presence tracking
202
208
  adapter.ts # AgentAdapter interface, prompt builder, handoff formatting
203
209
  registry.ts # Discovers teammates from .teammates/, loads SOUL.md + memory, type detection
210
+ compact.ts # Episodic memory compaction (daily→weekly→monthly) + auto-compaction
211
+ banner.ts # Animated startup banner with presence roster and segmented footer
204
212
  personas.ts # Persona loader — reads and parses bundled persona templates
213
+ theme.ts # Theme configuration, color palette, styled text shortcuts
214
+ cli-args.ts # CLI argument parsing, .teammates/ directory discovery
215
+ cli-utils.ts # Pure utility functions (relativeTime, wrapLine, findAtMention, etc.)
205
216
  types.ts # Core types (TeammateConfig, TaskResult, HandoffEnvelope, TeammateType, PresenceState)
206
217
  onboard.ts # Template copying, team import, onboarding/adaptation prompts
207
218
  dropdown.ts # Terminal dropdown/wordwheel widget
208
219
  adapters/
209
- cli-proxy.ts # Generic subprocess adapter with agent presets
220
+ cli-proxy.ts # Generic subprocess adapter with agent presets (claude, codex, aider)
221
+ copilot.ts # GitHub Copilot adapter
210
222
  echo.ts # Test adapter (no-op)
211
223
  cli/personas/ # 15 persona template files (pm.md, swe.md, devops.md, etc.)
212
224
  ```
@@ -246,6 +258,11 @@ Tests use [Vitest](https://vitest.dev/) and cover the core modules:
246
258
  | `src/adapter.test.ts` | `buildTeammatePrompt`, `formatHandoffContext` |
247
259
  | `src/orchestrator.test.ts` | Task routing, assignment, reset |
248
260
  | `src/registry.test.ts` | Teammate discovery, SOUL.md parsing (role, ownership), daily logs |
261
+ | `src/compact.test.ts` | Daily→weekly compaction, auto-compaction, partial merge |
262
+ | `src/personas.test.ts` | Persona loading and scaffolding |
263
+ | `src/theme.test.ts` | Theme configuration |
264
+ | `src/cli-args.test.ts` | Argument parsing, directory discovery |
265
+ | `src/cli-utils.test.ts` | Utility functions |
249
266
  | `src/adapters/echo.test.ts` | Echo adapter session and task execution |
250
267
 
251
268
  ## Dependencies
@@ -17,5 +17,34 @@ export declare function findAtMention(line: string, cursor: number): {
17
17
  } | null;
18
18
  /** Set of recognized image file extensions. */
19
19
  export declare const IMAGE_EXTS: Set<string>;
20
+ /** A single entry in the conversation history. */
21
+ export interface ConversationEntry {
22
+ role: string;
23
+ text: string;
24
+ }
25
+ /**
26
+ * Strip protocol artifacts (TO: header, handoff blocks, trailing JSON) from
27
+ * an agent's raw output, returning just the message body.
28
+ */
29
+ export declare function cleanResponseBody(rawOutput: string): string;
30
+ /**
31
+ * Format a conversation entry for inclusion in a prompt.
32
+ * Single-line text stays inline; multi-line text gets the body on the next line.
33
+ */
34
+ export declare function formatConversationEntry(role: string, text: string): string;
35
+ /**
36
+ * Build the conversation context section for a teammate prompt.
37
+ * Works backwards from newest entries, including whole entries up to the budget.
38
+ */
39
+ export declare function buildConversationContext(history: ConversationEntry[], summary: string, budget: number): string;
40
+ /**
41
+ * Find the split index where older conversation entries should be summarized.
42
+ * Returns 0 if everything fits within the budget (nothing to summarize).
43
+ */
44
+ export declare function findSummarizationSplit(history: ConversationEntry[], budget: number): number;
45
+ /**
46
+ * Build the summarization prompt text from entries being pushed out of the budget.
47
+ */
48
+ export declare function buildSummarizationPrompt(entries: ConversationEntry[], existingSummary: string): string;
20
49
  /** Check if a string looks like an image file path. */
21
50
  export declare function isImagePath(text: string): boolean;
package/dist/cli-utils.js CHANGED
@@ -61,6 +61,80 @@ export const IMAGE_EXTS = new Set([
61
61
  ".svg",
62
62
  ".ico",
63
63
  ]);
64
+ /**
65
+ * Strip protocol artifacts (TO: header, handoff blocks, trailing JSON) from
66
+ * an agent's raw output, returning just the message body.
67
+ */
68
+ export function cleanResponseBody(rawOutput) {
69
+ return rawOutput
70
+ .replace(/^TO:\s*\S+\s*\n/im, "")
71
+ .replace(/```handoff\s*\n@\w+\s*\n[\s\S]*?```/g, "")
72
+ .replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/g, "")
73
+ .trim();
74
+ }
75
+ /**
76
+ * Format a conversation entry for inclusion in a prompt.
77
+ * Single-line text stays inline; multi-line text gets the body on the next line.
78
+ */
79
+ export function formatConversationEntry(role, text) {
80
+ return text.includes("\n")
81
+ ? `**${role}:**\n${text}\n`
82
+ : `**${role}:** ${text}\n`;
83
+ }
84
+ /**
85
+ * Build the conversation context section for a teammate prompt.
86
+ * Works backwards from newest entries, including whole entries up to the budget.
87
+ */
88
+ export function buildConversationContext(history, summary, budget) {
89
+ if (history.length === 0 && !summary)
90
+ return "";
91
+ const parts = ["## Conversation History\n"];
92
+ if (summary) {
93
+ parts.push(`### Previous Conversation Summary\n\n${summary}\n`);
94
+ }
95
+ const entries = [];
96
+ let used = 0;
97
+ for (let i = history.length - 1; i >= 0; i--) {
98
+ const entry = formatConversationEntry(history[i].role, history[i].text);
99
+ if (used + entry.length > budget && entries.length > 0)
100
+ break;
101
+ entries.unshift(entry);
102
+ used += entry.length;
103
+ }
104
+ if (entries.length > 0)
105
+ parts.push(entries.join("\n"));
106
+ return parts.join("\n");
107
+ }
108
+ /**
109
+ * Find the split index where older conversation entries should be summarized.
110
+ * Returns 0 if everything fits within the budget (nothing to summarize).
111
+ */
112
+ export function findSummarizationSplit(history, budget) {
113
+ let recentChars = 0;
114
+ let splitIdx = history.length;
115
+ for (let i = history.length - 1; i >= 0; i--) {
116
+ const entry = formatConversationEntry(history[i].role, history[i].text);
117
+ if (recentChars + entry.length > budget)
118
+ break;
119
+ recentChars += entry.length;
120
+ splitIdx = i;
121
+ }
122
+ return splitIdx === history.length ? 0 : splitIdx;
123
+ }
124
+ /**
125
+ * Build the summarization prompt text from entries being pushed out of the budget.
126
+ */
127
+ export function buildSummarizationPrompt(entries, existingSummary) {
128
+ const entriesText = entries
129
+ .map((e) => e.text.includes("\n")
130
+ ? `**${e.role}:**\n${e.text}`
131
+ : `**${e.role}:** ${e.text}`)
132
+ .join("\n\n");
133
+ const instructions = `## Instructions\n\nReturn ONLY the ${existingSummary ? "updated " : ""}summary — no preamble, no explanation. The summary should:\n- Be a concise bulleted list of key topics discussed, decisions made, and work completed\n- Preserve important context that future messages might reference\n- Drop trivial or redundant details\n- Stay under 2000 characters\n- Do NOT include any output protocol (no TO:, no # Subject, no handoff blocks)`;
134
+ return existingSummary
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
+ : `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
+ }
64
138
  /** Check if a string looks like an image file path. */
65
139
  export function isImagePath(text) {
66
140
  // 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 { findAtMention, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
2
+ import { buildConversationContext, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, formatConversationEntry, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
3
3
  // ── relativeTime ────────────────────────────────────────────────────
4
4
  describe("relativeTime", () => {
5
5
  beforeEach(() => {
@@ -177,3 +177,187 @@ describe("IMAGE_EXTS", () => {
177
177
  expect(IMAGE_EXTS.size).toBe(8);
178
178
  });
179
179
  });
180
+ // ── cleanResponseBody ──────────────────────────────────────────────
181
+ describe("cleanResponseBody", () => {
182
+ it("strips TO: header", () => {
183
+ const raw = "TO: user\n# Subject\n\nBody text here";
184
+ expect(cleanResponseBody(raw)).toBe("# Subject\n\nBody text here");
185
+ });
186
+ it("strips TO: header case-insensitively", () => {
187
+ const raw = "to: User\n# Subject\n\nBody text here";
188
+ expect(cleanResponseBody(raw)).toBe("# Subject\n\nBody text here");
189
+ });
190
+ it("strips handoff blocks", () => {
191
+ const raw = "Some text\n\n```handoff\n@scribe\nDo this task\n```\n\nMore text";
192
+ expect(cleanResponseBody(raw)).toContain("Some text");
193
+ expect(cleanResponseBody(raw)).toContain("More text");
194
+ expect(cleanResponseBody(raw)).not.toContain("handoff");
195
+ expect(cleanResponseBody(raw)).not.toContain("@scribe");
196
+ });
197
+ it("strips multiple handoff blocks", () => {
198
+ const raw = "Text\n```handoff\n@scribe\ntask1\n```\nmiddle\n```handoff\n@beacon\ntask2\n```\nend";
199
+ expect(cleanResponseBody(raw)).toBe("Text\n\nmiddle\n\nend");
200
+ });
201
+ it("strips trailing JSON blocks", () => {
202
+ const raw = 'Body text\n\n```json\n{ "summary": "done" }\n```';
203
+ expect(cleanResponseBody(raw)).toBe("Body text");
204
+ });
205
+ it("strips all protocol artifacts together", () => {
206
+ const raw = 'TO: user\n# Done\n\nI finished the task.\n\n```handoff\n@pipeline\nDeploy it\n```\n\n```json\n{ "summary": "Done" }\n```';
207
+ expect(cleanResponseBody(raw)).toBe("# Done\n\nI finished the task.");
208
+ });
209
+ it("returns empty string for empty input", () => {
210
+ expect(cleanResponseBody("")).toBe("");
211
+ });
212
+ it("returns body unchanged when no protocol artifacts exist", () => {
213
+ expect(cleanResponseBody("Just a plain message")).toBe("Just a plain message");
214
+ });
215
+ it("trims surrounding whitespace", () => {
216
+ expect(cleanResponseBody(" \n Hello \n ")).toBe("Hello");
217
+ });
218
+ });
219
+ // ── formatConversationEntry ────────────────────────────────────────
220
+ describe("formatConversationEntry", () => {
221
+ it("formats single-line text inline", () => {
222
+ expect(formatConversationEntry("scribe", "Task completed")).toBe("**scribe:** Task completed\n");
223
+ });
224
+ it("formats multi-line text with body on next line", () => {
225
+ expect(formatConversationEntry("beacon", "Line 1\nLine 2")).toBe("**beacon:**\nLine 1\nLine 2\n");
226
+ });
227
+ it("treats single line with no newline as inline", () => {
228
+ expect(formatConversationEntry("user", "hello")).toBe("**user:** hello\n");
229
+ });
230
+ });
231
+ // ── buildConversationContext ────────────────────────────────────────
232
+ describe("buildConversationContext", () => {
233
+ it("returns empty string for empty history and no summary", () => {
234
+ expect(buildConversationContext([], "", 1000)).toBe("");
235
+ });
236
+ it("includes summary when present", () => {
237
+ const result = buildConversationContext([], "Previous topics discussed", 1000);
238
+ expect(result).toContain("## Conversation History");
239
+ expect(result).toContain("### Previous Conversation Summary");
240
+ expect(result).toContain("Previous topics discussed");
241
+ });
242
+ it("includes all entries when within budget", () => {
243
+ const history = [
244
+ { role: "stevenic", text: "Hello" },
245
+ { role: "scribe", text: "Hi there" },
246
+ { role: "stevenic", text: "Do the thing" },
247
+ ];
248
+ const result = buildConversationContext(history, "", 10_000);
249
+ expect(result).toContain("**stevenic:** Hello");
250
+ expect(result).toContain("**scribe:** Hi there");
251
+ expect(result).toContain("**stevenic:** Do the thing");
252
+ });
253
+ it("drops oldest entries when over budget", () => {
254
+ const history = [
255
+ { role: "old", text: "A".repeat(500) },
256
+ { role: "mid", text: "B".repeat(500) },
257
+ { role: "new", text: "C".repeat(500) },
258
+ ];
259
+ // Budget enough for ~2 entries but not 3
260
+ const budget = 1100;
261
+ const result = buildConversationContext(history, "", budget);
262
+ expect(result).not.toContain("**old:**");
263
+ expect(result).toContain("**new:**");
264
+ });
265
+ it("always includes at least the newest entry even if over budget", () => {
266
+ const history = [
267
+ { role: "beacon", text: "A".repeat(2000) },
268
+ ];
269
+ const result = buildConversationContext(history, "", 100);
270
+ expect(result).toContain("**beacon:**");
271
+ });
272
+ it("formats multi-line entries with body on next line", () => {
273
+ const history = [
274
+ { role: "scribe", text: "Line 1\nLine 2\nLine 3" },
275
+ ];
276
+ const result = buildConversationContext(history, "", 10_000);
277
+ expect(result).toContain("**scribe:**\nLine 1\nLine 2\nLine 3");
278
+ });
279
+ it("includes both summary and entries", () => {
280
+ const history = [
281
+ { role: "stevenic", text: "Latest message" },
282
+ ];
283
+ const result = buildConversationContext(history, "Earlier we discussed X", 10_000);
284
+ expect(result).toContain("### Previous Conversation Summary");
285
+ expect(result).toContain("Earlier we discussed X");
286
+ expect(result).toContain("**stevenic:** Latest message");
287
+ });
288
+ });
289
+ // ── findSummarizationSplit ─────────────────────────────────────────
290
+ describe("findSummarizationSplit", () => {
291
+ it("returns 0 when everything fits in budget", () => {
292
+ const history = [
293
+ { role: "a", text: "short" },
294
+ { role: "b", text: "also short" },
295
+ ];
296
+ expect(findSummarizationSplit(history, 10_000)).toBe(0);
297
+ });
298
+ it("returns 0 for empty history", () => {
299
+ expect(findSummarizationSplit([], 1000)).toBe(0);
300
+ });
301
+ it("returns split index when history exceeds budget", () => {
302
+ const history = [
303
+ { role: "old1", text: "A".repeat(400) },
304
+ { role: "old2", text: "B".repeat(400) },
305
+ { role: "new1", text: "C".repeat(400) },
306
+ { role: "new2", text: "D".repeat(400) },
307
+ ];
308
+ // Budget fits ~2 entries (~430 chars each with formatting)
309
+ const budget = 900;
310
+ const split = findSummarizationSplit(history, budget);
311
+ expect(split).toBeGreaterThan(0);
312
+ expect(split).toBeLessThan(history.length);
313
+ });
314
+ it("keeps newest entries and pushes oldest out", () => {
315
+ const history = [
316
+ { role: "oldest", text: "X".repeat(300) },
317
+ { role: "middle", text: "Y".repeat(300) },
318
+ { role: "newest", text: "Z".repeat(300) },
319
+ ];
320
+ // Budget fits 1 entry
321
+ const budget = 350;
322
+ const split = findSummarizationSplit(history, budget);
323
+ // Split should be 2 — entries 0 and 1 get summarized, entry 2 (newest) stays
324
+ expect(split).toBe(2);
325
+ });
326
+ it("returns 0 when single entry fits", () => {
327
+ const history = [{ role: "a", text: "hello" }];
328
+ expect(findSummarizationSplit(history, 10_000)).toBe(0);
329
+ });
330
+ });
331
+ // ── buildSummarizationPrompt ───────────────────────────────────────
332
+ describe("buildSummarizationPrompt", () => {
333
+ const entries = [
334
+ { role: "stevenic", text: "Build the feature" },
335
+ { role: "beacon", text: "Done, here's what I did" },
336
+ ];
337
+ it("builds a fresh summarization prompt when no existing summary", () => {
338
+ const prompt = buildSummarizationPrompt(entries, "");
339
+ expect(prompt).toContain("Summarize the conversation entries below");
340
+ expect(prompt).toContain("**stevenic:** Build the feature");
341
+ expect(prompt).toContain("**beacon:** Done, here's what I did");
342
+ expect(prompt).not.toContain("Current Summary");
343
+ });
344
+ it("builds an update prompt when existing summary is present", () => {
345
+ const prompt = buildSummarizationPrompt(entries, "Previously discussed X");
346
+ expect(prompt).toContain("Update the existing summary");
347
+ expect(prompt).toContain("## Current Summary");
348
+ expect(prompt).toContain("Previously discussed X");
349
+ expect(prompt).toContain("## New Entries to Incorporate");
350
+ });
351
+ it("includes instruction constraints", () => {
352
+ const prompt = buildSummarizationPrompt(entries, "");
353
+ expect(prompt).toContain("Stay under 2000 characters");
354
+ expect(prompt).toContain("Do NOT include any output protocol");
355
+ });
356
+ it("formats multi-line entries correctly", () => {
357
+ const multiLine = [
358
+ { role: "scribe", text: "Line 1\nLine 2" },
359
+ ];
360
+ const prompt = buildSummarizationPrompt(multiLine, "");
361
+ expect(prompt).toContain("**scribe:**\nLine 1\nLine 2");
362
+ });
363
+ });
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ import ora from "ora";
18
18
  import { DAILY_LOG_BUDGET_TOKENS, syncRecallIndex } from "./adapter.js";
19
19
  import { AnimatedBanner, } from "./banner.js";
20
20
  import { findTeammatesDir, PKG_VERSION, parseCliArgs, printUsage, resolveAdapter, } from "./cli-args.js";
21
- import { findAtMention, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
21
+ import { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
22
22
  import { autoCompactForBudget, buildWisdomPrompt, compactEpisodic, purgeStaleDailies, } from "./compact.js";
23
23
  import { PromptInput } from "./console/prompt-input.js";
24
24
  import { buildTitle } from "./console/startup.js";
@@ -44,9 +44,12 @@ class TeammatesREPL {
44
44
  storeResult(result) {
45
45
  this.lastResult = result;
46
46
  this.lastResults.set(result.teammate, result);
47
+ // Store the full response body in conversation history — not just the
48
+ // subject line. The 24k-token budget + auto-summarization handle size.
49
+ const body = cleanResponseBody(result.rawOutput ?? "");
47
50
  this.conversationHistory.push({
48
51
  role: result.teammate,
49
- text: result.summary,
52
+ text: body || result.summary,
50
53
  });
51
54
  }
52
55
  /**
@@ -152,27 +155,7 @@ class TeammatesREPL {
152
155
  /** Token budget for recent conversation history (24k tokens ≈ 96k chars). */
153
156
  static CONV_HISTORY_CHARS = 24_000 * 4;
154
157
  buildConversationContext() {
155
- if (this.conversationHistory.length === 0 && !this.conversationSummary)
156
- return "";
157
- const budget = TeammatesREPL.CONV_HISTORY_CHARS;
158
- const parts = ["## Conversation History\n"];
159
- // Include running summary of older conversation if present
160
- if (this.conversationSummary) {
161
- parts.push(`### Previous Conversation Summary\n\n${this.conversationSummary}\n`);
162
- }
163
- // Work backwards from newest — include whole entries up to 24k tokens
164
- const entries = [];
165
- let used = 0;
166
- for (let i = this.conversationHistory.length - 1; i >= 0; i--) {
167
- const line = `**${this.conversationHistory[i].role}:** ${this.conversationHistory[i].text}\n`;
168
- if (used + line.length > budget && entries.length > 0)
169
- break;
170
- entries.unshift(line);
171
- used += line.length;
172
- }
173
- if (entries.length > 0)
174
- parts.push(entries.join("\n"));
175
- return parts.join("\n");
158
+ return buildConvCtx(this.conversationHistory, this.conversationSummary, TeammatesREPL.CONV_HISTORY_CHARS);
176
159
  }
177
160
  /**
178
161
  * Check if conversation history exceeds the 24k token budget.
@@ -180,28 +163,11 @@ class TeammatesREPL {
180
163
  * and queue a summarization task to the coding agent.
181
164
  */
182
165
  maybeQueueSummarization() {
183
- const budget = TeammatesREPL.CONV_HISTORY_CHARS;
184
- // Calculate how many recent entries fit in the budget (newest first)
185
- let recentChars = 0;
186
- let splitIdx = this.conversationHistory.length;
187
- for (let i = this.conversationHistory.length - 1; i >= 0; i--) {
188
- const line = `**${this.conversationHistory[i].role}:** ${this.conversationHistory[i].text}\n`;
189
- if (recentChars + line.length > budget)
190
- break;
191
- recentChars += line.length;
192
- splitIdx = i;
193
- }
166
+ const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
194
167
  if (splitIdx === 0)
195
168
  return; // everything fits — nothing to summarize
196
- // Collect entries that are being pushed out
197
169
  const toSummarize = this.conversationHistory.slice(0, splitIdx);
198
- const entriesText = toSummarize
199
- .map((e) => `**${e.role}:** ${e.text}`)
200
- .join("\n");
201
- // Build the summarization prompt
202
- const prompt = this.conversationSummary
203
- ? `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${this.conversationSummary}\n\n## New Entries to Incorporate\n\n${entriesText}\n\n## Instructions\n\nReturn ONLY the updated summary — no preamble, no explanation. The summary should:\n- Be a concise bulleted list of key topics discussed, decisions made, and work completed\n- Preserve important context that future messages might reference\n- Drop trivial or redundant details\n- Stay under 2000 characters\n- Do NOT include any output protocol (no TO:, no # Subject, no handoff blocks)`
204
- : `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\n\nReturn ONLY the summary — no preamble, no explanation. The summary should:\n- Be a concise bulleted list of key topics discussed, decisions made, and work completed\n- Preserve important context that future messages might reference\n- Drop trivial or redundant details\n- Stay under 2000 characters\n- Do NOT include any output protocol (no TO:, no # Subject, no handoff blocks)`;
170
+ const prompt = buildSummarizationPrompt(toSummarize, this.conversationSummary);
205
171
  // Remove the summarized entries — they'll be captured in the summary
206
172
  this.conversationHistory.splice(0, splitIdx);
207
173
  // Queue the summarization task through the user's agent
@@ -346,12 +312,12 @@ class TeammatesREPL {
346
312
  // Keep adding segments from the front until we'd exceed maxLen
347
313
  let front = parts[0];
348
314
  for (let i = 1; i < parts.length - 1; i++) {
349
- const candidate = front + sep + parts[i] + sep + "..." + sep + last;
315
+ const candidate = `${front + sep + parts[i] + sep}...${sep}${last}`;
350
316
  if (candidate.length > maxLen)
351
317
  break;
352
318
  front += sep + parts[i];
353
319
  }
354
- return front + sep + "..." + sep + last;
320
+ return `${front + sep}...${sep}${last}`;
355
321
  }
356
322
  /** Format elapsed seconds as (Ns), (Nm Ns), or (Nh Nm Ns). */
357
323
  static formatElapsed(totalSeconds) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Agent-agnostic CLI for teammates. Routes tasks, manages handoffs, and plugs into any coding agent backend.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,8 +34,8 @@
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
36
  "@github/copilot-sdk": "^0.1.32",
37
- "@teammates/consolonia": "0.5.2",
38
- "@teammates/recall": "0.5.2",
37
+ "@teammates/consolonia": "0.5.3",
38
+ "@teammates/recall": "0.5.3",
39
39
  "chalk": "^5.6.2",
40
40
  "ora": "^9.3.0"
41
41
  },