@teammates/cli 0.6.0 → 0.6.2

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.js CHANGED
@@ -260,7 +260,7 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
260
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
261
  }
262
262
  // Memory updates
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.`, " - 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.");
264
264
  // Section Reinforcement — back-references from high-attention bottom edge to each section tag
265
265
  instrLines.push("", "### Section Reinforcement", "");
266
266
  instrLines.push("- Stay in character as defined in `<IDENTITY>` — never break persona or speak as a generic assistant.");
@@ -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
+ });
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@
8
8
  * teammates --dir <path> Override .teammates/ location
9
9
  */
10
10
  import { exec as execCb, execSync, spawnSync } from "node:child_process";
11
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
12
12
  import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
13
- import { dirname, join, resolve, sep } from "node:path";
13
+ import { basename, dirname, join, resolve, sep } from "node:path";
14
14
  import { createInterface } from "node:readline";
15
15
  import { App, ChatView, concat, esc, pen, renderMarkdown, stripAnsi, } from "@teammates/consolonia";
16
16
  import chalk from "chalk";
@@ -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 { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
21
+ import { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, compressConversationEntries, findAtMention, findSummarizationSplit, formatConversationEntry, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
22
22
  import { autoCompactForBudget, buildDailyCompressionPrompt, buildMigrationCompressionPrompt, buildWisdomPrompt, compactEpisodic, findUncompressedDailies, purgeStaleDailies, } from "./compact.js";
23
23
  import { PromptInput } from "./console/prompt-input.js";
24
24
  import { buildTitle } from "./console/startup.js";
@@ -121,8 +121,11 @@ class TeammatesREPL {
121
121
  if (this.chatView && cleaned) {
122
122
  const t = theme();
123
123
  const teammate = result.teammate;
124
- const replyId = `reply-${teammate}-${Date.now()}`;
124
+ const ts = Date.now();
125
+ const replyId = `reply-${teammate}-${ts}`;
126
+ const copyId = `copy-${teammate}-${ts}`;
125
127
  this._replyContexts.set(replyId, { teammate, message: cleaned });
128
+ this._copyContexts.set(copyId, cleaned);
126
129
  this.chatView.appendActionList([
127
130
  {
128
131
  id: replyId,
@@ -136,7 +139,7 @@ class TeammatesREPL {
136
139
  }),
137
140
  },
138
141
  {
139
- id: "copy",
142
+ id: copyId,
140
143
  normalStyle: this.makeSpan({
141
144
  text: " [copy]",
142
145
  style: { fg: t.textDim },
@@ -153,27 +156,25 @@ class TeammatesREPL {
153
156
  this.refreshTeammates();
154
157
  this.showPrompt();
155
158
  }
156
- /** Token budget for recent conversation history (24k tokens 96k chars). */
157
- static CONV_HISTORY_CHARS = 24_000 * 4;
158
- /** Threshold (chars) above which conversation history is written to a temp file. */
159
- static CONV_FILE_THRESHOLD = 8_000;
160
- buildConversationContext() {
161
- const context = buildConvCtx(this.conversationHistory, this.conversationSummary, TeammatesREPL.CONV_HISTORY_CHARS);
162
- // If the conversation context is very long, write it to a temp file
163
- // and return a pointer instead — reduces inline prompt noise.
164
- if (context.length > TeammatesREPL.CONV_FILE_THRESHOLD) {
165
- const tmpDir = join(this.teammatesDir, ".tmp");
166
- mkdirSync(tmpDir, { recursive: true });
167
- const convFile = join(tmpDir, "conversation.md");
168
- writeFileSync(convFile, context, "utf-8");
169
- return `## Conversation History\n\nThe full conversation history is in \`.teammates/.tmp/conversation.md\`. Read it before responding to understand prior context.`;
170
- }
171
- return context;
159
+ /** Target context window in tokens. Conversation history budget is derived from this. */
160
+ static TARGET_CONTEXT_TOKENS = 128_000;
161
+ /** Estimated tokens used by non-conversation prompt sections (identity, wisdom, logs, recall, instructions, task). */
162
+ static PROMPT_OVERHEAD_TOKENS = 32_000;
163
+ /** Chars-per-token approximation (matches adapter.ts). */
164
+ static CHARS_PER_TOKEN = 4;
165
+ /** Character budget for conversation history = (target overhead) × chars/token. */
166
+ static CONV_HISTORY_CHARS = (TeammatesREPL.TARGET_CONTEXT_TOKENS -
167
+ TeammatesREPL.PROMPT_OVERHEAD_TOKENS) *
168
+ TeammatesREPL.CHARS_PER_TOKEN;
169
+ buildConversationContext(_teammate, snapshot) {
170
+ const history = snapshot ? snapshot.history : this.conversationHistory;
171
+ const summary = snapshot ? snapshot.summary : this.conversationSummary;
172
+ return buildConvCtx(history, summary, TeammatesREPL.CONV_HISTORY_CHARS);
172
173
  }
173
174
  /**
174
- * Check if conversation history exceeds the 24k token budget.
175
+ * Check if conversation history exceeds the token budget.
175
176
  * If so, take the older entries that won't fit, combine with existing summary,
176
- * and queue a summarization task to the coding agent.
177
+ * and queue a summarization task to the coding agent for high-quality compression.
177
178
  */
178
179
  maybeQueueSummarization() {
179
180
  const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
@@ -191,6 +192,23 @@ class TeammatesREPL {
191
192
  });
192
193
  this.kickDrain();
193
194
  }
195
+ /**
196
+ * Pre-dispatch compression: if conversation history exceeds the token budget,
197
+ * mechanically compress older entries into bullet summaries BEFORE building the
198
+ * prompt. This ensures the prompt always fits within the target context window,
199
+ * even if the async agent-quality summarization hasn't completed yet.
200
+ */
201
+ preDispatchCompress() {
202
+ const totalChars = this.conversationHistory.reduce((sum, e) => sum + formatConversationEntry(e.role, e.text).length, 0);
203
+ if (totalChars <= TeammatesREPL.CONV_HISTORY_CHARS)
204
+ return;
205
+ const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
206
+ if (splitIdx === 0)
207
+ return;
208
+ const toCompress = this.conversationHistory.slice(0, splitIdx);
209
+ this.conversationSummary = compressConversationEntries(toCompress, this.conversationSummary);
210
+ this.conversationHistory.splice(0, splitIdx);
211
+ }
194
212
  adapterName;
195
213
  teammatesDir;
196
214
  taskQueue = [];
@@ -214,6 +232,8 @@ class TeammatesREPL {
214
232
  ctrlcPending = false; // true after first Ctrl+C, waiting for second
215
233
  ctrlcTimer = null;
216
234
  lastCleanedOutput = ""; // last teammate output for clipboard copy
235
+ /** Maps copy action IDs to the cleaned output text for that response. */
236
+ _copyContexts = new Map();
217
237
  autoApproveHandoffs = false;
218
238
  /** Last debug log file path per teammate — for /debug analysis. */
219
239
  lastDebugFiles = new Map();
@@ -284,11 +304,12 @@ class TeammatesREPL {
284
304
  this.statusFrame = 0;
285
305
  this.statusRotateIndex = 0;
286
306
  this.renderStatusFrame();
287
- // Animate spinner at ~80ms
307
+ // Animate spinner at ~200ms (fast enough for visual smoothness,
308
+ // slow enough to avoid saturating the render loop under load)
288
309
  this.statusTimer = setInterval(() => {
289
310
  this.statusFrame++;
290
311
  this.renderStatusFrame();
291
- }, 80);
312
+ }, 200);
292
313
  // Rotate through teammates every 3 seconds
293
314
  this.statusRotateTimer = setInterval(() => {
294
315
  if (this.activeTasks.size > 1) {
@@ -375,7 +396,7 @@ class TeammatesREPL {
375
396
  : cleanTask;
376
397
  if (this.chatView) {
377
398
  this.chatView.setProgress(concat(tp.accent(`${spinChar} ${displayName}... `), tp.muted(`${taskText}${suffix}`)));
378
- this.app.refresh();
399
+ this.app.scheduleRefresh();
379
400
  }
380
401
  else {
381
402
  const spinColor = this.statusFrame % 8 === 0 ? chalk.blue : chalk.blueBright;
@@ -1122,8 +1143,14 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1122
1143
  if (everyoneMatch) {
1123
1144
  const task = everyoneMatch[1];
1124
1145
  const names = allNames.filter((n) => n !== this.selfName && n !== this.adapterName);
1146
+ // Atomic snapshot: freeze conversation state ONCE so all agents see
1147
+ // the same context regardless of concurrent preDispatchCompress mutations.
1148
+ const contextSnapshot = {
1149
+ history: this.conversationHistory.map((e) => ({ ...e })),
1150
+ summary: this.conversationSummary,
1151
+ };
1125
1152
  for (const teammate of names) {
1126
- this.taskQueue.push({ type: "agent", teammate, task });
1153
+ this.taskQueue.push({ type: "agent", teammate, task, contextSnapshot });
1127
1154
  }
1128
1155
  const bg = this._userBg;
1129
1156
  const t = theme();
@@ -1271,6 +1298,8 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1271
1298
  catch {
1272
1299
  /* re-index failed — non-fatal, next startup will retry */
1273
1300
  }
1301
+ // Persist version LAST — only after all migration tasks finish
1302
+ this.commitVersionUpdate();
1274
1303
  }
1275
1304
  }
1276
1305
  }
@@ -1709,8 +1738,8 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1709
1738
  const userMdPath = join(teammatesDir, "USER.md");
1710
1739
  try {
1711
1740
  const content = readFileSync(userMdPath, "utf-8");
1712
- // Template placeholders contain "<your name>" — treat as not set up
1713
- return !content.trim() || content.includes("<your name>");
1741
+ // Template placeholders contain "<Your name>" — treat as not set up
1742
+ return !content.trim() || content.toLowerCase().includes("<your name>");
1714
1743
  }
1715
1744
  catch {
1716
1745
  // File doesn't exist
@@ -2112,9 +2141,51 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
2112
2141
  compact: new Set([0]),
2113
2142
  debug: new Set([0]),
2114
2143
  retro: new Set([0]),
2144
+ interrupt: new Set([0]),
2145
+ int: new Set([0]),
2115
2146
  };
2116
2147
  /** Build param-completion items for the current line, if any. */
2117
2148
  getParamItems(cmdName, argsBefore, partial) {
2149
+ // Script subcommand + name completion for /script
2150
+ if (cmdName === "script") {
2151
+ const completedArgs = argsBefore.trim()
2152
+ ? argsBefore.trim().split(/\s+/).length
2153
+ : 0;
2154
+ const lower = partial.toLowerCase();
2155
+ if (completedArgs === 0) {
2156
+ // First arg — suggest subcommands
2157
+ const subs = [
2158
+ { name: "list", desc: "List saved scripts" },
2159
+ { name: "run", desc: "Run an existing script" },
2160
+ ];
2161
+ return subs
2162
+ .filter((s) => s.name.startsWith(lower))
2163
+ .map((s) => ({
2164
+ label: s.name,
2165
+ description: s.desc,
2166
+ completion: `/script ${s.name} `,
2167
+ }));
2168
+ }
2169
+ if (completedArgs === 1 && argsBefore.trim() === "run") {
2170
+ // Second arg after "run" — suggest script filenames
2171
+ const scriptsDir = join(this.teammatesDir, this.selfName, "scripts");
2172
+ let files = [];
2173
+ try {
2174
+ files = readdirSync(scriptsDir).filter((f) => !f.startsWith("."));
2175
+ }
2176
+ catch {
2177
+ // directory doesn't exist yet
2178
+ }
2179
+ return files
2180
+ .filter((f) => f.toLowerCase().startsWith(lower))
2181
+ .map((f) => ({
2182
+ label: f,
2183
+ description: "saved script",
2184
+ completion: `/script run ${f}`,
2185
+ }));
2186
+ }
2187
+ return [];
2188
+ }
2118
2189
  // Service name completion for /configure
2119
2190
  if (cmdName === "configure" || cmdName === "config") {
2120
2191
  const completedArgs = argsBefore.trim()
@@ -2703,8 +2774,9 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
2703
2774
  if (id.startsWith("copy-cmd:")) {
2704
2775
  this.doCopy(id.slice("copy-cmd:".length));
2705
2776
  }
2706
- else if (id === "copy") {
2707
- this.doCopy(this.lastCleanedOutput || undefined);
2777
+ else if (id.startsWith("copy-")) {
2778
+ const text = this._copyContexts.get(id);
2779
+ this.doCopy(text || this.lastCleanedOutput || undefined);
2708
2780
  }
2709
2781
  else if (id.startsWith("retro-approve-") ||
2710
2782
  id.startsWith("retro-reject-")) {
@@ -3212,7 +3284,7 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3212
3284
  {
3213
3285
  name: "interrupt",
3214
3286
  aliases: ["int"],
3215
- usage: "/interrupt <teammate> [message]",
3287
+ usage: "/interrupt [teammate] [message]",
3216
3288
  description: "Interrupt a running agent and resume with a steering message",
3217
3289
  run: (args) => this.cmdInterrupt(args),
3218
3290
  },
@@ -3265,6 +3337,13 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3265
3337
  description: "Ask a quick side question without interrupting the main conversation",
3266
3338
  run: (args) => this.cmdBtw(args),
3267
3339
  },
3340
+ {
3341
+ name: "script",
3342
+ aliases: [],
3343
+ usage: "/script [description]",
3344
+ description: "Write and run reusable scripts via the coding agent",
3345
+ run: (args) => this.cmdScript(args),
3346
+ },
3268
3347
  {
3269
3348
  name: "theme",
3270
3349
  aliases: [],
@@ -3536,7 +3615,7 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3536
3615
  this.refreshView();
3537
3616
  }
3538
3617
  /**
3539
- * /interrupt <teammate> [message] — Kill a running agent and resume with context.
3618
+ * /interrupt [teammate] [message] — Kill a running agent and resume with context.
3540
3619
  */
3541
3620
  async cmdInterrupt(argsStr) {
3542
3621
  const parts = argsStr.trim().split(/\s+/);
@@ -3544,7 +3623,7 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3544
3623
  const steeringMessage = parts.slice(1).join(" ").trim() ||
3545
3624
  "Wrap up your current work and report what you've done so far.";
3546
3625
  if (!teammateName) {
3547
- this.feedLine(tp.warning(" Usage: /interrupt <teammate> [message]"));
3626
+ this.feedLine(tp.warning(" Usage: /interrupt [teammate] [message]"));
3548
3627
  this.refreshView();
3549
3628
  return;
3550
3629
  }
@@ -3656,9 +3735,16 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3656
3735
  try {
3657
3736
  {
3658
3737
  // btw and debug tasks skip conversation context (not part of main thread)
3659
- const extraContext = entry.type === "btw" || entry.type === "debug"
3660
- ? ""
3661
- : this.buildConversationContext();
3738
+ const isMainThread = entry.type !== "btw" && entry.type !== "debug";
3739
+ // Snapshot-aware context building: if the entry has a frozen snapshot
3740
+ // (@everyone), use it directly — no mutation of shared state.
3741
+ // Otherwise, compress live state as before.
3742
+ const snapshot = entry.type === "agent" ? entry.contextSnapshot : undefined;
3743
+ if (isMainThread && !snapshot)
3744
+ this.preDispatchCompress();
3745
+ const extraContext = isMainThread
3746
+ ? this.buildConversationContext(entry.teammate, snapshot)
3747
+ : "";
3662
3748
  let result = await this.orchestrator.assign({
3663
3749
  teammate: entry.teammate,
3664
3750
  task: entry.task,
@@ -4307,6 +4393,14 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4307
4393
  }
4308
4394
  }
4309
4395
  this.pendingMigrationSyncs = migrationCount;
4396
+ // If no migration tasks were actually queued, commit version now
4397
+ if (migrationCount === 0) {
4398
+ this.commitVersionUpdate();
4399
+ }
4400
+ }
4401
+ else if (versionUpdate) {
4402
+ // No migration needed — commit the version update immediately
4403
+ this.commitVersionUpdate();
4310
4404
  }
4311
4405
  this.kickDrain();
4312
4406
  // 3. Purge daily logs older than 30 days (disk + Vectra)
@@ -4334,8 +4428,8 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4334
4428
  }
4335
4429
  /**
4336
4430
  * Check if the CLI version has changed since last run.
4337
- * Updates settings.json with the current version.
4338
- * Returns the previous version if it changed, null otherwise.
4431
+ * Does NOT update settings.json call `commitVersionUpdate()` after
4432
+ * migration tasks are complete to persist the new version.
4339
4433
  */
4340
4434
  checkVersionUpdate() {
4341
4435
  const settingsPath = join(this.teammatesDir, "settings.json");
@@ -4344,17 +4438,29 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4344
4438
  settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
4345
4439
  }
4346
4440
  catch {
4347
- // No settings file or invalid JSON — create one
4441
+ // No settings file or invalid JSON
4348
4442
  }
4349
4443
  const previous = settings.cliVersion ?? "";
4350
4444
  const current = PKG_VERSION;
4351
4445
  if (previous === current)
4352
4446
  return null;
4353
- // Detect major/minor version change (not just patch)
4354
- const [prevMajor, prevMinor] = previous.split(".").map(Number);
4355
- const [curMajor, curMinor] = current.split(".").map(Number);
4356
- const isMajorMinor = previous !== "" && (prevMajor !== curMajor || prevMinor !== curMinor);
4357
- // Update the stored version
4447
+ return { previous, current };
4448
+ }
4449
+ /**
4450
+ * Persist the current CLI version to settings.json.
4451
+ * Called after all migration tasks complete (or immediately if no migration needed).
4452
+ */
4453
+ commitVersionUpdate() {
4454
+ const settingsPath = join(this.teammatesDir, "settings.json");
4455
+ let settings = {};
4456
+ try {
4457
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
4458
+ }
4459
+ catch {
4460
+ // No settings file or invalid JSON — create one
4461
+ }
4462
+ const previous = settings.cliVersion ?? "";
4463
+ const current = PKG_VERSION;
4358
4464
  settings.cliVersion = current;
4359
4465
  if (!settings.version)
4360
4466
  settings.version = 1;
@@ -4364,12 +4470,15 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4364
4470
  catch {
4365
4471
  /* write failed — non-fatal */
4366
4472
  }
4473
+ // Detect major/minor version change (not just patch)
4474
+ const [prevMajor, prevMinor] = previous.split(".").map(Number);
4475
+ const [curMajor, curMinor] = current.split(".").map(Number);
4476
+ const isMajorMinor = previous !== "" && (prevMajor !== curMajor || prevMinor !== curMinor);
4367
4477
  if (isMajorMinor) {
4368
4478
  this.feedLine(tp.accent(` ✔ Updated from v${previous} → v${current}`));
4369
4479
  this.feedLine();
4370
4480
  this.refreshView();
4371
4481
  }
4372
- return { previous, current };
4373
4482
  }
4374
4483
  async cmdCopy() {
4375
4484
  this.doCopy(); // copies entire session
@@ -4524,6 +4633,116 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4524
4633
  this.refreshView();
4525
4634
  this.kickDrain();
4526
4635
  }
4636
+ async cmdScript(argsStr) {
4637
+ const args = argsStr.trim();
4638
+ const scriptsDir = join(this.teammatesDir, this.selfName, "scripts");
4639
+ // /script (no args) — show usage
4640
+ if (!args) {
4641
+ this.feedLine();
4642
+ this.feedLine(tp.bold(" /script — write and run reusable scripts"));
4643
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
4644
+ this.feedLine(concat(tp.accent(" /script list".padEnd(36)), tp.text("List saved scripts")));
4645
+ this.feedLine(concat(tp.accent(" /script run <name>".padEnd(36)), tp.text("Run an existing script")));
4646
+ this.feedLine(concat(tp.accent(" /script <description>".padEnd(36)), tp.text("Create and run a new script")));
4647
+ this.feedLine();
4648
+ this.feedLine(tp.muted(` Scripts are saved to ${scriptsDir}`));
4649
+ this.feedLine();
4650
+ this.refreshView();
4651
+ return;
4652
+ }
4653
+ // /script list — list saved scripts
4654
+ if (args === "list") {
4655
+ let files = [];
4656
+ try {
4657
+ files = readdirSync(scriptsDir).filter((f) => !f.startsWith("."));
4658
+ }
4659
+ catch {
4660
+ // directory doesn't exist yet
4661
+ }
4662
+ this.feedLine();
4663
+ if (files.length === 0) {
4664
+ this.feedLine(tp.muted(" No scripts saved yet."));
4665
+ this.feedLine(tp.muted(" Use /script <description> to create one."));
4666
+ }
4667
+ else {
4668
+ this.feedLine(tp.bold(" Saved scripts"));
4669
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
4670
+ for (const f of files) {
4671
+ this.feedLine(concat(tp.accent(` ${f}`)));
4672
+ }
4673
+ }
4674
+ this.feedLine();
4675
+ this.refreshView();
4676
+ return;
4677
+ }
4678
+ // /script run <name> — run an existing script
4679
+ if (args.startsWith("run ")) {
4680
+ const name = args.slice(4).trim();
4681
+ if (!name) {
4682
+ this.feedLine(tp.muted(" Usage: /script run <name>"));
4683
+ this.refreshView();
4684
+ return;
4685
+ }
4686
+ // Find the script file (try exact match, then with common extensions)
4687
+ const candidates = [
4688
+ name,
4689
+ `${name}.sh`,
4690
+ `${name}.ts`,
4691
+ `${name}.js`,
4692
+ `${name}.ps1`,
4693
+ `${name}.py`,
4694
+ ];
4695
+ let scriptPath = null;
4696
+ for (const c of candidates) {
4697
+ const p = join(scriptsDir, c);
4698
+ if (existsSync(p)) {
4699
+ scriptPath = p;
4700
+ break;
4701
+ }
4702
+ }
4703
+ if (!scriptPath) {
4704
+ this.feedLine(tp.warning(` Script not found: ${name}`));
4705
+ this.feedLine(tp.muted(" Use /script list to see available scripts."));
4706
+ this.refreshView();
4707
+ return;
4708
+ }
4709
+ const scriptContent = readFileSync(scriptPath, "utf-8");
4710
+ const task = `Run the following script located at ${scriptPath}:\n\n\`\`\`\n${scriptContent}\n\`\`\`\n\nExecute it and report the results. If it fails, diagnose the issue and fix it.`;
4711
+ this.taskQueue.push({
4712
+ type: "script",
4713
+ teammate: this.selfName,
4714
+ task,
4715
+ });
4716
+ this.feedLine(concat(tp.muted(" Running script "), tp.accent(basename(scriptPath)), tp.muted(" → "), tp.accent(`@${this.adapterName}`)));
4717
+ this.feedLine();
4718
+ this.refreshView();
4719
+ this.kickDrain();
4720
+ return;
4721
+ }
4722
+ // /script <description> — create and run a new script
4723
+ const task = [
4724
+ `The user wants a reusable script. Their request:`,
4725
+ ``,
4726
+ args,
4727
+ ``,
4728
+ `Instructions:`,
4729
+ `1. Write the script and save it to the scripts directory: ${scriptsDir}`,
4730
+ `2. Create the directory if it doesn't exist.`,
4731
+ `3. Choose a short, descriptive filename (kebab-case, with appropriate extension like .sh, .ts, .js, .py, .ps1).`,
4732
+ `4. Make the script executable if applicable.`,
4733
+ `5. Run the script and report the results.`,
4734
+ `6. If the script needs to be parameterized, use command-line arguments.`,
4735
+ ].join("\n");
4736
+ this.taskQueue.push({
4737
+ type: "script",
4738
+ teammate: this.selfName,
4739
+ task,
4740
+ });
4741
+ this.feedLine(concat(tp.muted(" Script task → "), tp.accent(`@${this.adapterName}`)));
4742
+ this.feedLine();
4743
+ this.refreshView();
4744
+ this.kickDrain();
4745
+ }
4527
4746
  async cmdTheme() {
4528
4747
  const t = theme();
4529
4748
  this.feedLine();
package/dist/compact.js CHANGED
@@ -84,6 +84,7 @@ function buildWeeklySummary(weekKey, dailies, partial = false) {
84
84
  const lastDate = sorted[sorted.length - 1].date;
85
85
  const lines = [];
86
86
  lines.push("---");
87
+ lines.push("version: 0.6.0");
87
88
  lines.push(`type: weekly`);
88
89
  lines.push(`week: ${weekKey}`);
89
90
  lines.push(`period: ${firstDate} to ${lastDate}`);
@@ -110,6 +111,7 @@ function buildMonthlySummary(monthKey, weeklies) {
110
111
  const lastWeek = sorted[sorted.length - 1].week;
111
112
  const lines = [];
112
113
  lines.push("---");
114
+ lines.push("version: 0.6.0");
113
115
  lines.push(`type: monthly`);
114
116
  lines.push(`month: ${monthKey}`);
115
117
  lines.push(`period: ${firstWeek} to ${lastWeek}`);
@@ -586,6 +588,8 @@ For EACH file listed below:
586
588
  5. Start the file with this frontmatter:
587
589
  \`\`\`
588
590
  ---
591
+ version: 0.6.0
592
+ type: daily
589
593
  compressed: true
590
594
  ---
591
595
  \`\`\`
@@ -650,6 +654,8 @@ Keep the same markdown structure (# date header, ## Task headers) but make each
650
654
  Write the compressed version to \`.teammates/${basename(teammateDir)}/memory/${yesterdayStr}.md\`. Start the file with this frontmatter:
651
655
  \`\`\`
652
656
  ---
657
+ version: 0.6.0
658
+ type: daily
653
659
  compressed: true
654
660
  ---
655
661
  \`\`\`
package/dist/onboard.js CHANGED
@@ -108,6 +108,19 @@ async function isTeammateFolder(dirPath) {
108
108
  return false;
109
109
  }
110
110
  }
111
+ /**
112
+ * Detect whether a teammate folder is a human avatar (**Type:** human in SOUL.md).
113
+ * Human avatars are user-specific and should not be imported to other projects.
114
+ */
115
+ async function isHumanAvatar(dirPath) {
116
+ try {
117
+ const soul = await readFile(join(dirPath, "SOUL.md"), "utf-8");
118
+ return /\*\*Type:\*\*\s*human/i.test(soul);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
111
124
  /**
112
125
  * Import teammates from another project's .teammates/ dir.
113
126
  *
@@ -138,6 +151,9 @@ export async function importTeammates(sourceDir, targetDir) {
138
151
  if (entry.isDirectory() && !isNonTeammateEntry(entry.name)) {
139
152
  // Check if it's a teammate folder
140
153
  if (await isTeammateFolder(srcPath)) {
154
+ // Skip human avatar folders — those are user-specific, not team members
155
+ if (await isHumanAvatar(srcPath))
156
+ continue;
141
157
  // Skip if teammate already exists in target
142
158
  try {
143
159
  await stat(destPath);
@@ -165,18 +181,8 @@ export async function importTeammates(sourceDir, targetDir) {
165
181
  teammates.push(entry.name);
166
182
  }
167
183
  }
168
- else if (entry.isFile() && entry.name === "USER.md") {
169
- // Only USER.md transfers framework files (CROSS-TEAM.md, README.md,
170
- // PROTOCOL.md, TEMPLATE.md) are project-specific and get created fresh
171
- // from the template by copyTemplateFiles().
172
- try {
173
- await stat(destPath);
174
- }
175
- catch {
176
- await copyFile(srcPath, destPath);
177
- files.push(entry.name);
178
- }
179
- }
184
+ // USER.md is user-specific (gitignored) never import it.
185
+ // The new user creates their own profile during setup.
180
186
  }
181
187
  // Ensure .gitignore exists
182
188
  const gitignoreDest = join(targetDir, ".gitignore");
package/dist/registry.js CHANGED
@@ -100,8 +100,8 @@ async function loadDailyLogs(memoryDir) {
100
100
  // Only include daily logs (YYYY-MM-DD format), skip typed memory files
101
101
  if (!/^\d{4}-\d{2}-\d{2}$/.test(stem))
102
102
  continue;
103
- const content = await readFile(join(memoryDir, entry), "utf-8");
104
- logs.push({ date: stem, content });
103
+ const raw = await readFile(join(memoryDir, entry), "utf-8");
104
+ logs.push({ date: stem, content: raw });
105
105
  }
106
106
  // Most recent first
107
107
  logs.sort((a, b) => b.date.localeCompare(a.date));
@@ -124,8 +124,8 @@ async function loadWeeklyLogs(memoryDir) {
124
124
  // Match YYYY-Wnn format
125
125
  if (!/^\d{4}-W\d{2}$/.test(stem))
126
126
  continue;
127
- const content = await readFile(join(weeklyDir, entry), "utf-8");
128
- logs.push({ week: stem, content });
127
+ const raw = await readFile(join(weeklyDir, entry), "utf-8");
128
+ logs.push({ week: stem, content: raw });
129
129
  }
130
130
  // Most recent first
131
131
  logs.sort((a, b) => b.week.localeCompare(a.week));
package/dist/types.d.ts CHANGED
@@ -124,6 +124,14 @@ export type QueueEntry = {
124
124
  task: string;
125
125
  system?: boolean;
126
126
  migration?: boolean;
127
+ /** Frozen conversation snapshot taken at queue time (used by @everyone). */
128
+ contextSnapshot?: {
129
+ history: {
130
+ role: string;
131
+ text: string;
132
+ }[];
133
+ summary: string;
134
+ };
127
135
  } | {
128
136
  type: "compact";
129
137
  teammate: string;
@@ -140,6 +148,10 @@ export type QueueEntry = {
140
148
  type: "debug";
141
149
  teammate: string;
142
150
  task: string;
151
+ } | {
152
+ type: "script";
153
+ teammate: string;
154
+ task: string;
143
155
  } | {
144
156
  type: "summarize";
145
157
  teammate: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",