@teammates/cli 0.5.0 → 0.5.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
@@ -75,6 +75,14 @@ export declare function queryRecallContext(teammatesDir: string, teammateName: s
75
75
  * Wrapper around the recall library's Indexer.
76
76
  */
77
77
  export declare function syncRecallIndex(teammatesDir: string, teammate?: string): Promise<void>;
78
+ /**
79
+ * Context budget allocation:
80
+ * - Days 2-7 get up to DAILY_LOG_BUDGET tokens (whole entries)
81
+ * - Recall gets at least RECALL_MIN_BUDGET, plus whatever daily logs didn't use
82
+ * - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
83
+ * - Weekly summaries are excluded (already indexed by recall)
84
+ */
85
+ export declare const DAILY_LOG_BUDGET_TOKENS = 24000;
78
86
  /**
79
87
  * Build the full prompt for a teammate session.
80
88
  * Includes identity, memory, roster, output protocol, and the task.
package/dist/adapter.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * and translates between the orchestrator's protocol and the agent's native API.
7
7
  */
8
8
  import { platform } from "node:os";
9
- import { Indexer, buildQueryVariations, matchMemoryCatalog, multiSearch, } from "@teammates/recall";
9
+ import { buildQueryVariations, Indexer, matchMemoryCatalog, multiSearch, } from "@teammates/recall";
10
10
  /**
11
11
  * Query the recall index for context relevant to the task prompt.
12
12
  *
@@ -58,23 +58,16 @@ export async function syncRecallIndex(teammatesDir, teammate) {
58
58
  await indexer.syncAll();
59
59
  }
60
60
  }
61
- /**
62
- * Default token budget for the prompt wrapper (everything except the task).
63
- * ~64k tokens ≈ 256k chars at ~4 chars/token.
64
- * The task prompt itself is excluded from this budget — if a user pastes
65
- * a large input, that's intentional and we don't trim it.
66
- */
67
- const DEFAULT_TOKEN_BUDGET = 64_000;
61
+ /** Approximate chars per token for budget estimation. */
68
62
  const CHARS_PER_TOKEN = 4;
69
63
  /**
70
64
  * Context budget allocation:
71
65
  * - Days 2-7 get up to DAILY_LOG_BUDGET tokens (whole entries)
72
66
  * - Recall gets at least RECALL_MIN_BUDGET, plus whatever daily logs didn't use
73
- * - Last recall entry can push total up to CONTEXT_BUDGET + RECALL_OVERFLOW (36k)
67
+ * - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
74
68
  * - Weekly summaries are excluded (already indexed by recall)
75
69
  */
76
- const CONTEXT_BUDGET_TOKENS = 32_000;
77
- const DAILY_LOG_BUDGET_TOKENS = 24_000;
70
+ export const DAILY_LOG_BUDGET_TOKENS = 24_000;
78
71
  const RECALL_MIN_BUDGET_TOKENS = 8_000;
79
72
  const RECALL_OVERFLOW_TOKENS = 4_000;
80
73
  /** Estimate tokens from character count. */
@@ -97,57 +90,98 @@ function estimateTokens(text) {
97
90
  */
98
91
  export function buildTeammatePrompt(teammate, taskPrompt, options) {
99
92
  const parts = [];
100
- // ── Identity (required) ─────────────────────────────────────────
101
- parts.push(`# You are ${teammate.name}\n\n${teammate.soul}\n\n---\n`);
102
- // ── Wisdom (required) ───────────────────────────────────────────
93
+ // ── Top edge (high attention) ─────────────────────────────────────
94
+ // <IDENTITY> anchors persona
95
+ parts.push(`<IDENTITY>\n# You are ${teammate.name}\n\n${teammate.soul}\n`);
96
+ // <WISDOM> — stable knowledge
103
97
  if (teammate.wisdom.trim()) {
104
- parts.push(`## Your Wisdom\n\n${teammate.wisdom}\n\n---\n`);
98
+ parts.push(`<WISDOM>\n${teammate.wisdom}\n`);
99
+ }
100
+ // ── Reference data (middle — acceptable for "lost in the middle") ──
101
+ // <TEAM> — roster for handoffs
102
+ if (options?.roster && options.roster.length > 0) {
103
+ const lines = [
104
+ "<TEAM>",
105
+ "These are the other teammates you can hand off work to:\n",
106
+ ];
107
+ for (const t of options.roster) {
108
+ if (t.name === teammate.name)
109
+ continue;
110
+ const owns = t.ownership.primary.length > 0
111
+ ? ` — owns: ${t.ownership.primary.join(", ")}`
112
+ : "";
113
+ lines.push(`- **@${t.name}**: ${t.role}${owns}`);
114
+ }
115
+ parts.push(`${lines.join("\n")}\n`);
116
+ }
117
+ // <SERVICES> — installed services
118
+ if (options?.services && options.services.length > 0) {
119
+ const lines = [
120
+ "<SERVICES>",
121
+ "These services are installed and available for you to use:\n",
122
+ ];
123
+ for (const svc of options.services) {
124
+ lines.push(`### ${svc.name}\n`);
125
+ lines.push(svc.description);
126
+ lines.push(`\n**Usage:** \`${svc.usage}\`\n`);
127
+ }
128
+ parts.push(`${lines.join("\n")}\n`);
105
129
  }
106
- // ── Budget-allocated context (daily logs → recall) ──────────────
107
- // Today's log: always included, outside budget
108
- // Days 2-7: up to 24k tokens (whole entries)
109
- // Recall: at least 8k + unused daily budget, last entry may overflow by 4k
130
+ // <RECALL_TOOL> Pass 2: agent-driven search
131
+ parts.push(`<RECALL_TOOL>\nYou can search your own memories mid-task for additional context. This is useful when the pre-loaded memories don't cover what you need.\n\n**Usage:** Run this command via your shell/terminal tool:\n\`\`\`\nteammates-recall search "<your query>" --dir .teammates --teammate ${teammate.name} --no-sync --json\n\`\`\`\n\n**Tips:**\n- Use specific, descriptive queries ("hooks lifecycle event naming decision" not "hooks")\n- Search iteratively: query → read result → refine query\n- The \`--json\` flag returns structured results for easier parsing\n- Results include a \`score\` field (0-1) — higher is more relevant\n- You can omit \`--teammate\` to search across all teammates' memories\n`);
132
+ // <ENVIRONMENT> date/time + platform
133
+ const now = new Date();
134
+ const today = now.toISOString().slice(0, 10);
135
+ const os = platform();
136
+ const osLabel = os === "win32" ? "Windows" : os === "darwin" ? "macOS" : "Linux";
137
+ const slashNote = os === "win32"
138
+ ? "Use backslashes (`\\`) in file paths."
139
+ : "Use forward slashes (`/`) in file paths.";
140
+ // Extract timezone from USER.md if available
141
+ const tzMatch = options?.userProfile?.match(/\*\*Primary Timezone:\*\*\s*(.+)/);
142
+ const userTimezone = tzMatch?.[1]?.trim();
143
+ const tzLine = userTimezone ? `\n**Timezone:** ${userTimezone}` : "";
144
+ parts.push(`<ENVIRONMENT>\n**Current date:** ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} (${today})\n**Current time:** ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}${tzLine}\n**Environment:** ${osLabel} — ${slashNote}\n`);
145
+ // ── Session context (middle-to-lower) ─────────────────────────────
146
+ // <DAILY_LOGS> — today's log (never trimmed) + days 2-7 (budget-allocated)
110
147
  const todayLog = teammate.dailyLogs.slice(0, 1);
111
148
  const pastLogs = teammate.dailyLogs.slice(1, 7); // days 2-7
112
149
  let dailyBudget = DAILY_LOG_BUDGET_TOKENS;
113
- // Current daily log (today) never trimmed, always included
114
- if (todayLog.length > 0) {
115
- const todayLines = ["## Recent Daily Logs\n"];
150
+ if (todayLog.length > 0 || pastLogs.length > 0) {
151
+ const logLines = ["<DAILY_LOGS>"];
152
+ // Current daily log (today) never trimmed, always included
116
153
  for (const log of todayLog) {
117
- todayLines.push(`### ${log.date}\n${log.content}\n`);
154
+ logLines.push(`### ${log.date}\n${log.content}`);
118
155
  }
119
- parts.push(todayLines.join("\n"));
120
- }
121
- // Days 2-7 — whole entries, up to 24k tokens
122
- if (pastLogs.length > 0) {
123
- const lines = [];
156
+ // Days 2-7 — whole entries, up to 24k tokens
124
157
  for (const log of pastLogs) {
125
- const entry = `### ${log.date}\n${log.content}\n`;
158
+ const entry = `### ${log.date}\n${log.content}`;
126
159
  const cost = estimateTokens(entry);
127
160
  if (cost > dailyBudget)
128
161
  break;
129
- lines.push(entry);
162
+ logLines.push(entry);
130
163
  dailyBudget -= cost;
131
164
  }
132
- if (lines.length > 0)
133
- parts.push(lines.join("\n"));
165
+ parts.push(`${logLines.join("\n")}\n`);
166
+ }
167
+ // <USER_PROFILE> — always included when present
168
+ if (options?.userProfile?.trim()) {
169
+ parts.push(`<USER_PROFILE>\n${options.userProfile.trim()}\n`);
134
170
  }
135
- // Recall results gets at least 8k tokens, plus unused daily budget
136
- // Last entry may overflow by up to 4k tokens
171
+ // ── Task-adjacent context (close to task for maximum relevance) ───
172
+ // <RECALL_RESULTS> budget-allocated, adjacent to task
137
173
  const recallBudget = Math.max(RECALL_MIN_BUDGET_TOKENS, RECALL_MIN_BUDGET_TOKENS + dailyBudget);
138
174
  const recallResults = options?.recallResults ?? [];
139
175
  if (recallResults.length > 0) {
140
176
  const lines = [
141
- "## Relevant Memories (from recall search)\n",
177
+ "<RECALL_RESULTS>",
142
178
  "These memories were retrieved based on relevance to the current task:\n",
143
179
  ];
144
180
  const headerCost = estimateTokens(lines.join("\n"));
145
181
  let recallUsed = headerCost;
146
182
  for (const r of recallResults) {
147
- const label = r.contentType
148
- ? `[${r.contentType}] ${r.uri}`
149
- : r.uri;
150
- const entry = `### ${label}\n${r.text}\n`;
183
+ const label = r.contentType ? `[${r.contentType}] ${r.uri}` : r.uri;
184
+ const entry = `### ${label}\n${r.text}`;
151
185
  const cost = estimateTokens(entry);
152
186
  if (recallUsed + cost > recallBudget + RECALL_OVERFLOW_TOKENS)
153
187
  break;
@@ -158,85 +192,90 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
158
192
  break;
159
193
  }
160
194
  if (lines.length > 2) {
161
- lines.push("\n---\n");
162
- parts.push(lines.join("\n"));
195
+ parts.push(`${lines.join("\n")}\n`);
163
196
  }
164
197
  }
165
- // Close context section with separator if needed
166
- if (todayLog.length > 0 || pastLogs.length > 0) {
167
- const lastPart = parts[parts.length - 1];
168
- if (!lastPart.endsWith("---\n")) {
169
- parts.push("\n---\n");
170
- }
198
+ // <HANDOFF_CONTEXT> directly task-relevant when present
199
+ if (options?.handoffContext) {
200
+ parts.push(`<HANDOFF_CONTEXT>\n${options.handoffContext}\n`);
201
+ }
202
+ // ── The question ──────────────────────────────────────────────────
203
+ // <TASK> — always included, excluded from budget
204
+ parts.push(`<TASK>\n${taskPrompt}\n`);
205
+ // ── Bottom edge (high attention) — all instructions merged ────────
206
+ // <INSTRUCTIONS> — output protocol, handoffs, session state, memory updates
207
+ const instrLines = [
208
+ "<INSTRUCTIONS>",
209
+ "",
210
+ "### Output Protocol (CRITICAL)",
211
+ "",
212
+ "**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.",
213
+ "",
214
+ "Format your response as:",
215
+ "",
216
+ "```",
217
+ "TO: user",
218
+ "# <Subject line>",
219
+ "",
220
+ "<Body — full markdown response>",
221
+ "```",
222
+ "",
223
+ "**Rules:**",
224
+ "- **You MUST end your turn with visible text output.** A turn that ends with only tool calls and no text is a failed turn.",
225
+ "- The `# Subject` line is REQUIRED — it becomes the message title.",
226
+ "- Always write a substantive body. Never return just the subject.",
227
+ "- Use markdown: headings, lists, code blocks, bold, etc.",
228
+ "- **Write your text response FIRST, then update session/memory files.** This ensures visible output even if the agent turn ends early.",
229
+ "",
230
+ "### Handoffs",
231
+ "",
232
+ "To delegate work to a teammate, you MUST include a fenced code block with the language tag `handoff` in your text output. **This is the ONLY way to trigger a handoff.** Mentioning a handoff in plain English does NOT work — the system parses the fenced block, not your prose.",
233
+ "",
234
+ "Exact format (include the triple backticks exactly as shown):",
235
+ "",
236
+ " ```handoff",
237
+ " @<teammate-name>",
238
+ " <task description with full context>",
239
+ " ```",
240
+ "",
241
+ "Rules:",
242
+ `- Only hand off to teammates listed in \`<TEAM>\`.`,
243
+ "- Do as much work as you can BEFORE handing off.",
244
+ '- Do NOT just say "I\'ll hand this off" in prose — that does nothing. You MUST use the fenced block.',
245
+ ];
246
+ // Session state (conditional)
247
+ if (options?.sessionFile) {
248
+ instrLines.push("", "### Session State", "", `Your session file is at: \`${options.sessionFile}\``, "", "**After writing your text response**, 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.");
249
+ }
250
+ // Memory updates
251
+ instrLines.push("", "### Memory Updates", "", "**After writing your text response**, 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.");
252
+ // Section Reinforcement — back-references from high-attention bottom edge to each section tag
253
+ instrLines.push("", "### Section Reinforcement", "");
254
+ instrLines.push("- Stay in character as defined in `<IDENTITY>` — never break persona or speak as a generic assistant.");
255
+ if (teammate.wisdom.trim()) {
256
+ instrLines.push("- Apply lessons from `<WISDOM>` before proposing solutions — do not repeat past mistakes.");
171
257
  }
172
- // ── Team roster (required, small) ───────────────────────────────
173
258
  if (options?.roster && options.roster.length > 0) {
174
- const lines = [
175
- "## Your Team\n",
176
- "These are the other teammates you can hand off work to:\n",
177
- ];
178
- for (const t of options.roster) {
179
- if (t.name === teammate.name)
180
- continue;
181
- const owns = t.ownership.primary.length > 0
182
- ? ` — owns: ${t.ownership.primary.join(", ")}`
183
- : "";
184
- lines.push(`- **@${t.name}**: ${t.role}${owns}`);
185
- }
186
- lines.push("\n---\n");
187
- parts.push(lines.join("\n"));
259
+ instrLines.push("- Only hand off to teammates listed in `<TEAM>` using the handoff block format above.");
188
260
  }
189
- // ── Installed services (required, small) ────────────────────────
190
261
  if (options?.services && options.services.length > 0) {
191
- const lines = [
192
- "## Available Services\n",
193
- "These services are installed and available for you to use:\n",
194
- ];
195
- for (const svc of options.services) {
196
- lines.push(`### ${svc.name}\n`);
197
- lines.push(svc.description);
198
- lines.push(`\n**Usage:** \`${svc.usage}\`\n`);
199
- }
200
- lines.push("\n---\n");
201
- parts.push(lines.join("\n"));
262
+ instrLines.push("- Use tools and services from `<SERVICES>` when they fit the task — do not reinvent what is already available.");
202
263
  }
203
- // ── Recall tool (Pass 2 agent-driven search) ─────────────────
204
- // Tell the agent it can search memories mid-task via the CLI tool
205
- parts.push(`## Recall Memory Search Tool\n\nYou can search your own memories mid-task for additional context. This is useful when the pre-loaded memories don't cover what you need.\n\n**Usage:** Run this command via your shell/terminal tool:\n\`\`\`\nteammates-recall search "<your query>" --dir .teammates --teammate ${teammate.name} --no-sync --json\n\`\`\`\n\n**Tips:**\n- Use specific, descriptive queries ("hooks lifecycle event naming decision" not "hooks")\n- Search iteratively: query → read result → refine query\n- The \`--json\` flag returns structured results for easier parsing\n- Results include a \`score\` field (0-1) — higher is more relevant\n- You can omit \`--teammate\` to search across all teammates' memories\n\n---\n`);
206
- // ── Handoff context (required when present) ─────────────────────
207
- if (options?.handoffContext) {
208
- parts.push(`## Handoff Context\n\n${options.handoffContext}\n\n---\n`);
209
- }
210
- // ── Output protocol (required — BEFORE session/memory updates) ──
211
- // Placed first so agents produce text response before doing housekeeping.
212
- // When output protocol is after memory updates, agents often end their turn
213
- // with file edits and produce no visible text output.
214
- parts.push(`## Output Protocol (CRITICAL)\n\n**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.\n\nFormat your response as:\n\n\`\`\`\nTO: user\n# <Subject line>\n\n<Body — full markdown response>\n\`\`\`\n\n**Handoffs:** To hand off work to a teammate, include a fenced handoff block anywhere in your response:\n\n\`\`\`\n\`\`\`handoff\n@<teammate>\n<task description — what you need them to do, with full context>\n\`\`\`\n\`\`\`\n\n**Rules:**\n- **You MUST end your turn with visible text output.** A turn that ends with only tool calls and no text is a failed turn.\n- The \`# Subject\` line is REQUIRED — it becomes the message title.\n- Always write a substantive body. Never return just the subject.\n- Use markdown: headings, lists, code blocks, bold, etc.\n- Do as much work as you can before handing off.\n- Only hand off to teammates listed in "Your Team" above.\n- The handoff block can appear anywhere in your response — it will be detected automatically.\n- **Write your text response FIRST, then update session/memory files.** This ensures visible output even if the agent turn ends early.\n\n---\n`);
215
- // ── Session state (required) ────────────────────────────────────
216
- if (options?.sessionFile) {
217
- parts.push(`## Session State\n\nYour session file is at: \`${options.sessionFile}\`\n\n**After writing your text response**, append a brief entry to this file with:\n- What you did\n- Key decisions made\n- Files changed\n- Anything the next task should know\n\nThis is how you maintain continuity across tasks. Always read it, always update it.\n\n---\n`);
264
+ instrLines.push("- If pre-loaded context is insufficient, use `<RECALL_TOOL>` to search for additional memories before giving up.", "- Respect platform, date, and path conventions from `<ENVIRONMENT>`.");
265
+ if (todayLog.length > 0 || pastLogs.length > 0) {
266
+ instrLines.push("- Check `<DAILY_LOGS>` for prior work on this topic before starting avoid duplicating what was already done today.");
218
267
  }
219
- // ── Memory updates (required) ───────────────────────────────────
220
- const today = new Date().toISOString().slice(0, 10);
221
- parts.push(`## Memory Updates\n\n**After writing your text response**, update your memory files:\n\n1. **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.\n - What you did\n - Key decisions made\n - Files changed\n - Anything the next task should know\n\n2. **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.\n\n3. **WISDOM.md** — Do not edit directly. Wisdom entries are distilled from typed memories during compaction.\n\nThese files are your persistent memory. Without them, your next session starts from scratch.\n\n---\n`);
222
- // ── Current date/time + environment (required, small) ───────────
223
- const now = new Date();
224
- const os = platform();
225
- const osLabel = os === "win32" ? "Windows" : os === "darwin" ? "macOS" : "Linux";
226
- const slashNote = os === "win32"
227
- ? "Use backslashes (`\\`) in file paths."
228
- : "Use forward slashes (`/`) in file paths.";
229
- // Extract timezone from USER.md if available
230
- const tzMatch = options?.userProfile?.match(/\*\*Primary Timezone:\*\*\s*(.+)/);
231
- const userTimezone = tzMatch?.[1]?.trim();
232
- const tzLine = userTimezone ? `\n**Timezone:** ${userTimezone}` : "";
233
- parts.push(`**Current date:** ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} (${today})\n**Current time:** ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}${tzLine}\n**Environment:** ${osLabel} — ${slashNote}\n\n---\n`);
234
- // ── User profile (always included when present) ────────────────
235
268
  if (options?.userProfile?.trim()) {
236
- parts.push(`## User Profile\n\n${options.userProfile.trim()}\n\n---\n`);
269
+ instrLines.push("- Honor the user's role, preferences, and communication style from `<USER_PROFILE>`.");
270
+ }
271
+ if (recallResults.length > 0) {
272
+ instrLines.push("- Incorporate relevant context from `<RECALL_RESULTS>` into your response — these memories were retrieved for a reason.");
273
+ }
274
+ if (options?.handoffContext) {
275
+ instrLines.push("- When `<HANDOFF_CONTEXT>` is present, address its requirements and open questions directly.");
237
276
  }
238
- // ── Task (always included, excluded from budget) ────────────────
239
- parts.push(`## Task\n\n${taskPrompt}\n\n---\n\n**REMINDER: Write your text response (TO: user) FIRST, then update session/memory files. A turn with only file edits and no text output is a failed turn.**`);
277
+ instrLines.push("- Your response must answer `<TASK>` — everything else is supporting context.", "", "**REMINDER: Write your text response (TO: user) FIRST, then update session/memory files. A turn with only file edits and no text output is a failed turn.**");
278
+ parts.push(instrLines.join("\n"));
240
279
  return parts.join("\n");
241
280
  }
242
281
  /**
@@ -25,27 +25,28 @@ describe("buildTeammatePrompt", () => {
25
25
  });
26
26
  it("includes the task", () => {
27
27
  const prompt = buildTeammatePrompt(makeConfig(), "fix the bug");
28
- expect(prompt).toContain("## Task");
28
+ expect(prompt).toContain("<TASK>");
29
29
  expect(prompt).toContain("fix the bug");
30
30
  });
31
31
  it("includes output protocol", () => {
32
32
  const prompt = buildTeammatePrompt(makeConfig(), "task");
33
- expect(prompt).toContain("## Output Protocol");
33
+ expect(prompt).toContain("<INSTRUCTIONS>");
34
+ expect(prompt).toContain("Output Protocol");
34
35
  expect(prompt).toContain("TO: user");
35
36
  expect(prompt).toContain("```handoff");
36
37
  });
37
38
  it("includes memory updates section", () => {
38
39
  const prompt = buildTeammatePrompt(makeConfig(), "task");
39
- expect(prompt).toContain("## Memory Updates");
40
+ expect(prompt).toContain("### Memory Updates");
40
41
  expect(prompt).toContain(".teammates/beacon/memory/");
41
42
  });
42
43
  it("skips wisdom section when empty", () => {
43
44
  const prompt = buildTeammatePrompt(makeConfig({ wisdom: "" }), "task");
44
- expect(prompt).not.toContain("## Your Wisdom");
45
+ expect(prompt).not.toContain("<WISDOM>");
45
46
  });
46
47
  it("includes wisdom when present", () => {
47
48
  const prompt = buildTeammatePrompt(makeConfig({ wisdom: "Some important wisdom" }), "task");
48
- expect(prompt).toContain("## Your Wisdom");
49
+ expect(prompt).toContain("<WISDOM>");
49
50
  expect(prompt).toContain("Some important wisdom");
50
51
  });
51
52
  it("includes daily logs (up to 7)", () => {
@@ -60,7 +61,7 @@ describe("buildTeammatePrompt", () => {
60
61
  { date: "2026-03-06", content: "Should be excluded" },
61
62
  ];
62
63
  const prompt = buildTeammatePrompt(makeConfig({ dailyLogs: logs }), "task");
63
- expect(prompt).toContain("## Recent Daily Logs");
64
+ expect(prompt).toContain("<DAILY_LOGS>");
64
65
  expect(prompt).toContain("2026-03-13");
65
66
  expect(prompt).toContain("2026-03-07");
66
67
  expect(prompt).not.toContain("2026-03-06");
@@ -80,7 +81,7 @@ describe("buildTeammatePrompt", () => {
80
81
  },
81
82
  ];
82
83
  const prompt = buildTeammatePrompt(makeConfig(), "task", { roster });
83
- expect(prompt).toContain("## Your Team");
84
+ expect(prompt).toContain("<TEAM>");
84
85
  expect(prompt).toContain("@scribe");
85
86
  expect(prompt).toContain("Documentation writer.");
86
87
  // Should not list self in roster
@@ -90,14 +91,14 @@ describe("buildTeammatePrompt", () => {
90
91
  const prompt = buildTeammatePrompt(makeConfig(), "task", {
91
92
  handoffContext: "Handed off from scribe with files changed",
92
93
  });
93
- expect(prompt).toContain("## Handoff Context");
94
+ expect(prompt).toContain("<HANDOFF_CONTEXT>");
94
95
  expect(prompt).toContain("Handed off from scribe");
95
96
  });
96
97
  it("includes session file when provided", () => {
97
98
  const prompt = buildTeammatePrompt(makeConfig(), "task", {
98
99
  sessionFile: "/tmp/beacon-session.md",
99
100
  });
100
- expect(prompt).toContain("## Session State");
101
+ expect(prompt).toContain("### Session State");
101
102
  expect(prompt).toContain("/tmp/beacon-session.md");
102
103
  });
103
104
  it("drops daily logs that exceed the 24k daily budget", () => {
@@ -130,11 +131,17 @@ describe("buildTeammatePrompt", () => {
130
131
  const recallText = "R".repeat(20_000); // ~5k tokens — fits in 8k min
131
132
  const prompt = buildTeammatePrompt(config, "task", {
132
133
  recallResults: [
133
- { teammate: "beacon", uri: "memory/decision_foo.md", text: recallText, score: 0.9, contentType: "typed_memory" },
134
+ {
135
+ teammate: "beacon",
136
+ uri: "memory/decision_foo.md",
137
+ text: recallText,
138
+ score: 0.9,
139
+ contentType: "typed_memory",
140
+ },
134
141
  ],
135
142
  });
136
143
  expect(prompt).toContain("2026-03-17");
137
- expect(prompt).toContain("## Relevant Memories");
144
+ expect(prompt).toContain("<RECALL_RESULTS>");
138
145
  });
139
146
  it("recall gets unused daily log budget", () => {
140
147
  // Small daily logs leave most of 24k unused — recall gets the surplus.
@@ -148,10 +155,16 @@ describe("buildTeammatePrompt", () => {
148
155
  const recallText = "R".repeat(80_000); // ~20k tokens — fits in (8k + ~24k unused)
149
156
  const prompt = buildTeammatePrompt(config, "task", {
150
157
  recallResults: [
151
- { teammate: "beacon", uri: "memory/big.md", text: recallText, score: 0.9, contentType: "typed_memory" },
158
+ {
159
+ teammate: "beacon",
160
+ uri: "memory/big.md",
161
+ text: recallText,
162
+ score: 0.9,
163
+ contentType: "typed_memory",
164
+ },
152
165
  ],
153
166
  });
154
- expect(prompt).toContain("## Relevant Memories");
167
+ expect(prompt).toContain("<RECALL_RESULTS>");
155
168
  expect(prompt).toContain("memory/big.md");
156
169
  });
157
170
  it("weekly summaries are excluded (indexed by recall)", () => {
@@ -160,8 +173,8 @@ describe("buildTeammatePrompt", () => {
160
173
  weeklyLogs: [{ week: "2026-W11", content: "short summary" }],
161
174
  });
162
175
  const prompt = buildTeammatePrompt(config, "task");
163
- expect(prompt).toContain("## Recent Daily Logs");
164
- expect(prompt).not.toContain("## Recent Weekly Summaries");
176
+ expect(prompt).toContain("<DAILY_LOGS>");
177
+ expect(prompt).not.toContain("Weekly Summaries");
165
178
  });
166
179
  it("excludes task prompt from budget calculation", () => {
167
180
  // Large task prompt should not trigger trimming of wrapper sections
@@ -171,7 +184,7 @@ describe("buildTeammatePrompt", () => {
171
184
  });
172
185
  const prompt = buildTeammatePrompt(config, bigTask);
173
186
  // Daily logs should still be included despite the huge task
174
- expect(prompt).toContain("## Recent Daily Logs");
187
+ expect(prompt).toContain("<DAILY_LOGS>");
175
188
  expect(prompt).toContain("small log");
176
189
  });
177
190
  });
@@ -20,7 +20,8 @@ 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 { buildTeammatePrompt, queryRecallContext } from "../adapter.js";
23
+ import { DAILY_LOG_BUDGET_TOKENS, buildTeammatePrompt, queryRecallContext, } from "../adapter.js";
24
+ import { autoCompactForBudget } from "../compact.js";
24
25
  export const PRESETS = {
25
26
  claude: {
26
27
  name: "claude",
@@ -152,6 +153,16 @@ export class CliProxyAdapter {
152
153
  const recall = teammatesDir
153
154
  ? await queryRecallContext(teammatesDir, teammate.name, prompt)
154
155
  : undefined;
156
+ // Auto-compact daily logs if they exceed the token budget
157
+ if (teammatesDir) {
158
+ const teammateDir = join(teammatesDir, teammate.name);
159
+ const compacted = await autoCompactForBudget(teammateDir, DAILY_LOG_BUDGET_TOKENS);
160
+ if (compacted) {
161
+ // Filter compacted dates out of in-memory daily logs
162
+ const compactedSet = new Set(compacted.compactedDates);
163
+ teammate.dailyLogs = teammate.dailyLogs.filter((log) => !compactedSet.has(log.date));
164
+ }
165
+ }
155
166
  // Read USER.md for injection into the prompt
156
167
  let userProfile;
157
168
  if (teammatesDir) {
@@ -481,8 +492,12 @@ function parseMessageProtocol(output, teammateName, _teammateNames) {
481
492
  break;
482
493
  }
483
494
  }
484
- // Find all ```handoff blocks
495
+ // Find all ```handoff blocks (primary) + natural-language fallback
485
496
  const handoffBlocks = findHandoffBlocks(output);
497
+ if (handoffBlocks.length === 0) {
498
+ // Fallback: detect natural-language handoff patterns mentioning known teammates
499
+ handoffBlocks.push(...findNaturalLanguageHandoffs(output, _teammateNames));
500
+ }
486
501
  const handoffs = handoffBlocks.map((h) => ({
487
502
  from: teammateName,
488
503
  to: h.target,
@@ -523,6 +538,47 @@ function findHandoffBlocks(output) {
523
538
  }
524
539
  return results;
525
540
  }
541
+ /**
542
+ * Fallback handoff detector: catches natural-language handoff patterns when
543
+ * the agent fails to use the ```handoff fenced block format.
544
+ *
545
+ * Looks for sentences like:
546
+ * - "hand off to @beacon: implement the feature"
547
+ * - "handing this to @scribe for documentation"
548
+ * - "I'll delegate to @pipeline"
549
+ * - "queued a handoff to @beacon"
550
+ *
551
+ * Only triggers if the @mentioned name is in the known teammate list.
552
+ * Extracts the surrounding sentence as the task description.
553
+ */
554
+ function findNaturalLanguageHandoffs(output, teammateNames) {
555
+ if (teammateNames.length === 0)
556
+ return [];
557
+ const results = [];
558
+ const seen = new Set();
559
+ // Pattern: handoff-related verb/noun near @teammate
560
+ const pattern = /(?:hand(?:off|ing off| off| this off)|delegat(?:e|ing)|pass(?:ing)? (?:this |it )?(?:to|off to)|queued? (?:a )?handoff (?:to|for))\s+@(\w+)\b[.:,]?\s*(.*)/gi;
561
+ let match;
562
+ while ((match = pattern.exec(output)) !== null) {
563
+ const target = match[1].toLowerCase();
564
+ if (!teammateNames.includes(target))
565
+ continue;
566
+ if (seen.has(target))
567
+ continue;
568
+ seen.add(target);
569
+ // Use the rest of the sentence as the task, or a generic description
570
+ let task = match[2]
571
+ .replace(/\n.*/s, "") // first line only
572
+ .replace(/[.!]+$/, "") // strip trailing punctuation
573
+ .trim();
574
+ if (!task || task.length < 5) {
575
+ task =
576
+ "(handoff detected from natural language — no task details provided)";
577
+ }
578
+ results.push({ target, task });
579
+ }
580
+ return results;
581
+ }
526
582
  /** Extract file paths from agent output. */
527
583
  export function parseChangedFiles(output) {
528
584
  const files = new Set();
@@ -12,7 +12,8 @@
12
12
  import { mkdir, readFile, writeFile } from "node:fs/promises";
13
13
  import { join } from "node:path";
14
14
  import { approveAll, CopilotClient, } from "@github/copilot-sdk";
15
- import { buildTeammatePrompt, queryRecallContext } from "../adapter.js";
15
+ import { DAILY_LOG_BUDGET_TOKENS, buildTeammatePrompt, queryRecallContext, } from "../adapter.js";
16
+ import { autoCompactForBudget } from "../compact.js";
16
17
  import { parseResult } from "./cli-proxy.js";
17
18
  // ─── Adapter ─────────────────────────────────────────────────────────
18
19
  let nextId = 1;
@@ -72,6 +73,15 @@ export class CopilotAdapter {
72
73
  const recall = teammatesDir
73
74
  ? await queryRecallContext(teammatesDir, teammate.name, prompt)
74
75
  : undefined;
76
+ // Auto-compact daily logs if they exceed the token budget
77
+ if (teammatesDir) {
78
+ const teammateDir = join(teammatesDir, teammate.name);
79
+ const compacted = await autoCompactForBudget(teammateDir, DAILY_LOG_BUDGET_TOKENS);
80
+ if (compacted) {
81
+ const compactedSet = new Set(compacted.compactedDates);
82
+ teammate.dailyLogs = teammate.dailyLogs.filter((log) => !compactedSet.has(log.date));
83
+ }
84
+ }
75
85
  // Read USER.md for injection into the prompt
76
86
  let userProfile;
77
87
  if (teammatesDir) {
@@ -12,7 +12,9 @@ export class EchoAdapter {
12
12
  return `echo-${teammate.name}-${nextId++}`;
13
13
  }
14
14
  async executeTask(_sessionId, teammate, prompt, options) {
15
- const fullPrompt = options?.raw ? prompt : buildTeammatePrompt(teammate, prompt);
15
+ const fullPrompt = options?.raw
16
+ ? prompt
17
+ : buildTeammatePrompt(teammate, prompt);
16
18
  return {
17
19
  teammate: teammate.name,
18
20
  success: true,
package/dist/banner.js CHANGED
@@ -78,7 +78,11 @@ export class AnimatedBanner extends Control {
78
78
  // Service status rows
79
79
  for (const svc of info.services) {
80
80
  const isBundledOrConfigured = svc.status === "bundled" || svc.status === "configured";
81
- const icon = isBundledOrConfigured ? "● " : svc.status === "not-configured" ? "◐ " : "○ ";
81
+ const icon = isBundledOrConfigured
82
+ ? "● "
83
+ : svc.status === "not-configured"
84
+ ? "◐ "
85
+ : "○ ";
82
86
  const color = isBundledOrConfigured ? tp.success : tp.warning;
83
87
  const label = svc.status === "bundled"
84
88
  ? "bundled"