@teammates/cli 0.5.2 → 0.6.0

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/cli.js CHANGED
@@ -18,10 +18,11 @@ 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";
22
- import { autoCompactForBudget, buildWisdomPrompt, compactEpisodic, purgeStaleDailies, } from "./compact.js";
21
+ import { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
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";
25
+ import { buildConversationLog } from "./log-parser.js";
25
26
  import { buildImportAdaptationPrompt, copyTemplateFiles, getOnboardingPrompt, importTeammates, } from "./onboard.js";
26
27
  import { Orchestrator } from "./orchestrator.js";
27
28
  import { loadPersonas, scaffoldFromPersona } from "./personas.js";
@@ -44,9 +45,12 @@ class TeammatesREPL {
44
45
  storeResult(result) {
45
46
  this.lastResult = result;
46
47
  this.lastResults.set(result.teammate, result);
48
+ // Store the full response body in conversation history — not just the
49
+ // subject line. The 24k-token budget + auto-summarization handle size.
50
+ const body = cleanResponseBody(result.rawOutput ?? "");
47
51
  this.conversationHistory.push({
48
52
  role: result.teammate,
49
- text: result.summary,
53
+ text: body || result.summary,
50
54
  });
51
55
  }
52
56
  /**
@@ -151,28 +155,20 @@ class TeammatesREPL {
151
155
  }
152
156
  /** Token budget for recent conversation history (24k tokens ≈ 96k chars). */
153
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;
154
160
  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");
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;
176
172
  }
177
173
  /**
178
174
  * Check if conversation history exceeds the 24k token budget.
@@ -180,28 +176,11 @@ class TeammatesREPL {
180
176
  * and queue a summarization task to the coding agent.
181
177
  */
182
178
  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
- }
179
+ const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
194
180
  if (splitIdx === 0)
195
181
  return; // everything fits — nothing to summarize
196
- // Collect entries that are being pushed out
197
182
  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)`;
183
+ const prompt = buildSummarizationPrompt(toSummarize, this.conversationSummary);
205
184
  // Remove the summarized entries — they'll be captured in the summary
206
185
  this.conversationHistory.splice(0, splitIdx);
207
186
  // Queue the summarization task through the user's agent
@@ -221,6 +200,8 @@ class TeammatesREPL {
221
200
  systemActive = new Map();
222
201
  /** Agents currently in a silent retry — suppress all events. */
223
202
  silentAgents = new Set();
203
+ /** Counter for pending migration compression tasks — triggers re-index when it hits 0. */
204
+ pendingMigrationSyncs = 0;
224
205
  /** Per-agent drain locks — prevents double-draining a single agent. */
225
206
  agentDrainLocks = new Map();
226
207
  /** Stored pasted text keyed by paste number, expanded on Enter. */
@@ -242,6 +223,8 @@ class TeammatesREPL {
242
223
  pendingHandoffs = [];
243
224
  /** Pending retro proposals awaiting user approval. */
244
225
  pendingRetroProposals = [];
226
+ /** Pending cross-folder violations awaiting user decision. */
227
+ pendingViolations = [];
245
228
  /** Maps reply action IDs to their context (teammate + message). */
246
229
  _replyContexts = new Map();
247
230
  /** Quoted reply text to expand on next submit. */
@@ -346,12 +329,12 @@ class TeammatesREPL {
346
329
  // Keep adding segments from the front until we'd exceed maxLen
347
330
  let front = parts[0];
348
331
  for (let i = 1; i < parts.length - 1; i++) {
349
- const candidate = front + sep + parts[i] + sep + "..." + sep + last;
332
+ const candidate = `${front + sep + parts[i] + sep}...${sep}${last}`;
350
333
  if (candidate.length > maxLen)
351
334
  break;
352
335
  front += sep + parts[i];
353
336
  }
354
- return front + sep + "..." + sep + last;
337
+ return `${front + sep}...${sep}${last}`;
355
338
  }
356
339
  /** Format elapsed seconds as (Ns), (Nm Ns), or (Nh Nm Ns). */
357
340
  static formatElapsed(totalSeconds) {
@@ -761,6 +744,135 @@ class TeammatesREPL {
761
744
  return;
762
745
  }
763
746
  }
747
+ /**
748
+ * Audit a task result for cross-folder writes.
749
+ * AI teammates must not write to another teammate's folder.
750
+ * Returns violating file paths (relative), or empty array if clean.
751
+ */
752
+ auditCrossFolderWrites(teammate, changedFiles) {
753
+ // Normalize .teammates/ prefix for comparison
754
+ const tmPrefix = ".teammates/";
755
+ const ownPrefix = `${tmPrefix}${teammate}/`;
756
+ return changedFiles.filter((f) => {
757
+ const normalized = f.replace(/\\/g, "/");
758
+ // Only care about files inside .teammates/
759
+ if (!normalized.startsWith(tmPrefix))
760
+ return false;
761
+ // Own folder is fine
762
+ if (normalized.startsWith(ownPrefix))
763
+ return false;
764
+ // Shared folders (_prefix) are fine
765
+ const subPath = normalized.slice(tmPrefix.length);
766
+ if (subPath.startsWith("_"))
767
+ return false;
768
+ // Ephemeral folders (.prefix) are fine
769
+ if (subPath.startsWith("."))
770
+ return false;
771
+ // Root-level shared files (USER.md, settings.json, CROSS-TEAM.md, etc.)
772
+ if (!subPath.includes("/"))
773
+ return false;
774
+ // Everything else is a violation
775
+ return true;
776
+ });
777
+ }
778
+ /**
779
+ * Show cross-folder violation warning with [revert] / [allow] actions.
780
+ */
781
+ showViolationWarning(teammate, violations) {
782
+ const t = theme();
783
+ this.feedLine(tp.warning(` ⚠ @${teammate} wrote to another teammate's folder:`));
784
+ for (const f of violations) {
785
+ this.feedLine(tp.muted(` ${f}`));
786
+ }
787
+ if (this.chatView) {
788
+ const violationId = `violation-${Date.now()}`;
789
+ const actionIdx = this.chatView.feedLineCount;
790
+ this.chatView.appendActionList([
791
+ {
792
+ id: `revert-${violationId}`,
793
+ normalStyle: this.makeSpan({
794
+ text: " [revert]",
795
+ style: { fg: t.error },
796
+ }),
797
+ hoverStyle: this.makeSpan({
798
+ text: " [revert]",
799
+ style: { fg: t.accent },
800
+ }),
801
+ },
802
+ {
803
+ id: `allow-${violationId}`,
804
+ normalStyle: this.makeSpan({
805
+ text: " [allow]",
806
+ style: { fg: t.textDim },
807
+ }),
808
+ hoverStyle: this.makeSpan({
809
+ text: " [allow]",
810
+ style: { fg: t.accent },
811
+ }),
812
+ },
813
+ ]);
814
+ this.pendingViolations.push({
815
+ id: violationId,
816
+ teammate,
817
+ files: violations,
818
+ actionIdx,
819
+ });
820
+ }
821
+ }
822
+ /**
823
+ * Handle revert/allow actions for cross-folder violations.
824
+ */
825
+ handleViolationAction(actionId) {
826
+ const revertMatch = actionId.match(/^revert-(violation-.+)$/);
827
+ if (revertMatch) {
828
+ const vId = revertMatch[1];
829
+ const idx = this.pendingViolations.findIndex((v) => v.id === vId);
830
+ if (idx >= 0 && this.chatView) {
831
+ const v = this.pendingViolations.splice(idx, 1)[0];
832
+ // Revert violating files via git checkout
833
+ for (const f of v.files) {
834
+ try {
835
+ execSync(`git checkout -- "${f}"`, {
836
+ cwd: resolve(this.teammatesDir, ".."),
837
+ stdio: "pipe",
838
+ });
839
+ }
840
+ catch {
841
+ // File might be untracked — try git rm
842
+ try {
843
+ execSync(`git rm -f "${f}"`, {
844
+ cwd: resolve(this.teammatesDir, ".."),
845
+ stdio: "pipe",
846
+ });
847
+ }
848
+ catch {
849
+ // Best effort — file may already be clean
850
+ }
851
+ }
852
+ }
853
+ this.chatView.updateFeedLine(v.actionIdx, this.makeSpan({
854
+ text: ` reverted ${v.files.length} file(s)`,
855
+ style: { fg: theme().success },
856
+ }));
857
+ this.refreshView();
858
+ }
859
+ return;
860
+ }
861
+ const allowMatch = actionId.match(/^allow-(violation-.+)$/);
862
+ if (allowMatch) {
863
+ const vId = allowMatch[1];
864
+ const idx = this.pendingViolations.findIndex((v) => v.id === vId);
865
+ if (idx >= 0 && this.chatView) {
866
+ const v = this.pendingViolations.splice(idx, 1)[0];
867
+ this.chatView.updateFeedLine(v.actionIdx, this.makeSpan({
868
+ text: " allowed",
869
+ style: { fg: theme().textDim },
870
+ }));
871
+ this.refreshView();
872
+ }
873
+ return;
874
+ }
875
+ }
764
876
  /** Handle bulk handoff actions. */
765
877
  handleBulkHandoff(action) {
766
878
  if (!this.chatView)
@@ -1147,6 +1259,20 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1147
1259
  }
1148
1260
  finally {
1149
1261
  this.systemActive.delete(taskId);
1262
+ // Migration tasks: decrement counter and re-index when all are done
1263
+ if (entry.type === "agent" && entry.migration) {
1264
+ this.pendingMigrationSyncs--;
1265
+ if (this.pendingMigrationSyncs <= 0) {
1266
+ try {
1267
+ await syncRecallIndex(this.teammatesDir);
1268
+ this.feedLine(tp.success(" ✔ v0.6.0 migration complete — indexes rebuilt"));
1269
+ this.refreshView();
1270
+ }
1271
+ catch {
1272
+ /* re-index failed — non-fatal, next startup will retry */
1273
+ }
1274
+ }
1275
+ }
1150
1276
  }
1151
1277
  }
1152
1278
  // ─── Onboarding ───────────────────────────────────────────────────
@@ -2584,6 +2710,9 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
2584
2710
  id.startsWith("retro-reject-")) {
2585
2711
  this.handleRetroAction(id);
2586
2712
  }
2713
+ else if (id.startsWith("revert-") || id.startsWith("allow-")) {
2714
+ this.handleViolationAction(id);
2715
+ }
2587
2716
  else if (id.startsWith("approve-") || id.startsWith("reject-")) {
2588
2717
  this.handleHandoffAction(id);
2589
2718
  }
@@ -3080,6 +3209,13 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3080
3209
  description: "Cancel a queued task by number",
3081
3210
  run: (args) => this.cmdCancel(args),
3082
3211
  },
3212
+ {
3213
+ name: "interrupt",
3214
+ aliases: ["int"],
3215
+ usage: "/interrupt <teammate> [message]",
3216
+ description: "Interrupt a running agent and resume with a steering message",
3217
+ run: (args) => this.cmdInterrupt(args),
3218
+ },
3083
3219
  {
3084
3220
  name: "init",
3085
3221
  aliases: ["onboard", "setup"],
@@ -3399,6 +3535,114 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3399
3535
  this.feedLine(concat(tp.muted(" Cancelled: "), tp.accent(`@${cancelDisplay}`), tp.muted(" — "), tp.text(removed.task.slice(0, 60))));
3400
3536
  this.refreshView();
3401
3537
  }
3538
+ /**
3539
+ * /interrupt <teammate> [message] — Kill a running agent and resume with context.
3540
+ */
3541
+ async cmdInterrupt(argsStr) {
3542
+ const parts = argsStr.trim().split(/\s+/);
3543
+ const teammateName = parts[0]?.replace(/^@/, "").toLowerCase();
3544
+ const steeringMessage = parts.slice(1).join(" ").trim() ||
3545
+ "Wrap up your current work and report what you've done so far.";
3546
+ if (!teammateName) {
3547
+ this.feedLine(tp.warning(" Usage: /interrupt <teammate> [message]"));
3548
+ this.refreshView();
3549
+ return;
3550
+ }
3551
+ // Resolve display name → internal name
3552
+ const resolvedName = teammateName === this.adapterName ? this.selfName : teammateName;
3553
+ // Check if the teammate has an active task
3554
+ const activeEntry = this.agentActive.get(resolvedName);
3555
+ if (!activeEntry) {
3556
+ this.feedLine(tp.warning(` @${teammateName} has no active task to interrupt.`));
3557
+ this.refreshView();
3558
+ return;
3559
+ }
3560
+ // Check if the adapter supports killing
3561
+ const adapter = this.orchestrator.getAdapter();
3562
+ if (!adapter?.killAgent) {
3563
+ this.feedLine(tp.warning(" This adapter does not support interruption."));
3564
+ this.refreshView();
3565
+ return;
3566
+ }
3567
+ // Show interruption status
3568
+ const displayName = resolvedName === this.selfName ? this.adapterName : resolvedName;
3569
+ this.feedLine(concat(tp.warning(" ⚡ Interrupting "), tp.accent(`@${displayName}`), tp.warning("...")));
3570
+ this.refreshView();
3571
+ try {
3572
+ // Kill the agent process and capture its output
3573
+ const spawnResult = await adapter.killAgent(resolvedName);
3574
+ if (!spawnResult) {
3575
+ this.feedLine(tp.warning(` @${displayName} process already exited.`));
3576
+ this.refreshView();
3577
+ return;
3578
+ }
3579
+ // Get the original full prompt for this agent
3580
+ const _originalFullPrompt = this.lastTaskPrompts.get(resolvedName) ?? "";
3581
+ const originalTask = activeEntry.task;
3582
+ // Parse the conversation log from available sources
3583
+ const presetName = adapter.name ?? "unknown";
3584
+ const { log, toolCallCount, filesChanged } = buildConversationLog(spawnResult.debugFile, spawnResult.stdout, presetName);
3585
+ // Build the resume prompt
3586
+ const resumePrompt = this.buildResumePrompt(originalTask, log, steeringMessage, toolCallCount, filesChanged);
3587
+ // Report what happened
3588
+ const elapsed = this.activeTasks.get(resolvedName)?.startTime
3589
+ ? `${((Date.now() - this.activeTasks.get(resolvedName).startTime) / 1000).toFixed(0)}s`
3590
+ : "unknown";
3591
+ this.feedLine(concat(tp.success(" ⚡ Interrupted "), tp.accent(`@${displayName}`), tp.muted(` (${elapsed}, ${toolCallCount} tool calls, ${filesChanged.length} files changed)`)));
3592
+ this.feedLine(concat(tp.muted(" Resuming with: "), tp.text(steeringMessage.slice(0, 70))));
3593
+ this.refreshView();
3594
+ // Clean up the active task state — the drainAgentQueue loop will see
3595
+ // the agent as inactive and the queue entry was already removed
3596
+ this.activeTasks.delete(resolvedName);
3597
+ this.agentActive.delete(resolvedName);
3598
+ if (this.activeTasks.size === 0)
3599
+ this.stopStatusAnimation();
3600
+ // Queue the resumed task
3601
+ this.taskQueue.push({
3602
+ type: "agent",
3603
+ teammate: resolvedName,
3604
+ task: resumePrompt,
3605
+ });
3606
+ this.kickDrain();
3607
+ }
3608
+ catch (err) {
3609
+ this.feedLine(tp.error(` ✖ Failed to interrupt @${displayName}: ${err?.message ?? String(err)}`));
3610
+ this.refreshView();
3611
+ }
3612
+ }
3613
+ /**
3614
+ * Build a resume prompt from the original task, conversation log, and steering message.
3615
+ */
3616
+ buildResumePrompt(originalTask, conversationLog, steeringMessage, toolCallCount, filesChanged) {
3617
+ const parts = [];
3618
+ parts.push("<RESUME_CONTEXT>");
3619
+ parts.push("This is a resumed task. You were previously working on this task but were interrupted.");
3620
+ parts.push("Below is the log of what you accomplished before the interruption.");
3621
+ parts.push("");
3622
+ parts.push("DO NOT repeat work that is already done. Check the filesystem for files you already wrote.");
3623
+ parts.push("Continue from where you left off.");
3624
+ parts.push("");
3625
+ parts.push("## What You Did Before Interruption");
3626
+ parts.push("");
3627
+ parts.push(`Tool calls: ${toolCallCount}`);
3628
+ if (filesChanged.length > 0) {
3629
+ parts.push(`Files changed: ${filesChanged.slice(0, 20).join(", ")}${filesChanged.length > 20 ? ` (+${filesChanged.length - 20} more)` : ""}`);
3630
+ }
3631
+ parts.push("");
3632
+ parts.push(conversationLog);
3633
+ parts.push("");
3634
+ parts.push("## Interruption");
3635
+ parts.push("");
3636
+ parts.push(steeringMessage);
3637
+ parts.push("");
3638
+ parts.push("## Your Task Now");
3639
+ parts.push("");
3640
+ parts.push("Continue the original task from where you left off. The original task was:");
3641
+ parts.push("");
3642
+ parts.push(originalTask);
3643
+ parts.push("</RESUME_CONTEXT>");
3644
+ return parts.join("\n");
3645
+ }
3402
3646
  /** Drain user tasks for a single agent — runs in parallel with other agents.
3403
3647
  * System tasks are handled separately by runSystemTask(). */
3404
3648
  async drainAgentQueue(agent) {
@@ -3463,6 +3707,14 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3463
3707
  }
3464
3708
  // Display the (possibly retried) result to the user
3465
3709
  this.displayTaskResult(result, entry.type);
3710
+ // Audit cross-folder writes for AI teammates
3711
+ const tmConfig = this.orchestrator.getRegistry().get(entry.teammate);
3712
+ if (tmConfig?.type === "ai" && result.changedFiles.length > 0) {
3713
+ const violations = this.auditCrossFolderWrites(entry.teammate, result.changedFiles);
3714
+ if (violations.length > 0) {
3715
+ this.showViolationWarning(entry.teammate, violations);
3716
+ }
3717
+ }
3466
3718
  // Write debug entry — skip for debug analysis tasks (avoid recursion)
3467
3719
  if (entry.type !== "debug") {
3468
3720
  this.writeDebugEntry(entry.teammate, entry.task, result, startTime);
@@ -3967,7 +4219,21 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3967
4219
  }
3968
4220
  }
3969
4221
  }
4222
+ /** Compare two semver strings. Returns true if `a` is less than `b`. */
4223
+ static semverLessThan(a, b) {
4224
+ const pa = a.split(".").map(Number);
4225
+ const pb = b.split(".").map(Number);
4226
+ for (let i = 0; i < 3; i++) {
4227
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
4228
+ return true;
4229
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
4230
+ return false;
4231
+ }
4232
+ return false;
4233
+ }
3970
4234
  async startupMaintenance() {
4235
+ // Check and update installed CLI version
4236
+ const versionUpdate = this.checkVersionUpdate();
3971
4237
  const tmpDir = join(this.teammatesDir, ".tmp");
3972
4238
  // Clean up debug log files older than 1 day
3973
4239
  const debugDir = join(tmpDir, "debug");
@@ -3994,7 +4260,56 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3994
4260
  for (const name of teammates) {
3995
4261
  await this.runCompact(name, true);
3996
4262
  }
3997
- // 2. Purge daily logs older than 30 days (disk + Vectra)
4263
+ // 2. Compress previous day's log for each teammate (queued as system tasks)
4264
+ for (const name of teammates) {
4265
+ try {
4266
+ const compression = await buildDailyCompressionPrompt(join(this.teammatesDir, name));
4267
+ if (compression) {
4268
+ this.taskQueue.push({
4269
+ type: "agent",
4270
+ teammate: name,
4271
+ task: compression.prompt,
4272
+ system: true,
4273
+ });
4274
+ }
4275
+ }
4276
+ catch {
4277
+ /* compression check failed — non-fatal */
4278
+ }
4279
+ }
4280
+ // 2b. v0.6.0 migration — compress ALL uncompressed daily logs + re-index
4281
+ const needsMigration = versionUpdate &&
4282
+ (versionUpdate.previous === "" ||
4283
+ TeammatesREPL.semverLessThan(versionUpdate.previous, "0.6.0"));
4284
+ if (needsMigration) {
4285
+ this.feedLine(tp.accent(" ℹ Migrating to v0.6.0 — compressing daily logs..."));
4286
+ this.refreshView();
4287
+ let migrationCount = 0;
4288
+ for (const name of teammates) {
4289
+ try {
4290
+ const uncompressed = await findUncompressedDailies(join(this.teammatesDir, name));
4291
+ if (uncompressed.length === 0)
4292
+ continue;
4293
+ const prompt = await buildMigrationCompressionPrompt(join(this.teammatesDir, name), name, uncompressed);
4294
+ if (prompt) {
4295
+ migrationCount++;
4296
+ this.taskQueue.push({
4297
+ type: "agent",
4298
+ teammate: name,
4299
+ task: prompt,
4300
+ system: true,
4301
+ migration: true,
4302
+ });
4303
+ }
4304
+ }
4305
+ catch {
4306
+ /* migration compression failed — non-fatal */
4307
+ }
4308
+ }
4309
+ this.pendingMigrationSyncs = migrationCount;
4310
+ }
4311
+ this.kickDrain();
4312
+ // 3. Purge daily logs older than 30 days (disk + Vectra)
3998
4313
  const { Indexer } = await import("@teammates/recall");
3999
4314
  const indexer = new Indexer({ teammatesDir: this.teammatesDir });
4000
4315
  for (const name of teammates) {
@@ -4009,7 +4324,7 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4009
4324
  /* purge failed — non-fatal */
4010
4325
  }
4011
4326
  }
4012
- // 3. Sync recall indexes (bundled library call)
4327
+ // 4. Sync recall indexes (bundled library call)
4013
4328
  try {
4014
4329
  await syncRecallIndex(this.teammatesDir);
4015
4330
  }
@@ -4017,6 +4332,45 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4017
4332
  /* sync failed — non-fatal */
4018
4333
  }
4019
4334
  }
4335
+ /**
4336
+ * 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.
4339
+ */
4340
+ checkVersionUpdate() {
4341
+ const settingsPath = join(this.teammatesDir, "settings.json");
4342
+ let settings = {};
4343
+ try {
4344
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
4345
+ }
4346
+ catch {
4347
+ // No settings file or invalid JSON — create one
4348
+ }
4349
+ const previous = settings.cliVersion ?? "";
4350
+ const current = PKG_VERSION;
4351
+ if (previous === current)
4352
+ 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
4358
+ settings.cliVersion = current;
4359
+ if (!settings.version)
4360
+ settings.version = 1;
4361
+ try {
4362
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
4363
+ }
4364
+ catch {
4365
+ /* write failed — non-fatal */
4366
+ }
4367
+ if (isMajorMinor) {
4368
+ this.feedLine(tp.accent(` ✔ Updated from v${previous} → v${current}`));
4369
+ this.feedLine();
4370
+ this.refreshView();
4371
+ }
4372
+ return { previous, current };
4373
+ }
4020
4374
  async cmdCopy() {
4021
4375
  this.doCopy(); // copies entire session
4022
4376
  }
package/dist/compact.d.ts CHANGED
@@ -67,3 +67,32 @@ export declare function buildWisdomPrompt(teammateDir: string, teammateName: str
67
67
  * Returns the list of deleted filenames.
68
68
  */
69
69
  export declare function purgeStaleDailies(teammateDir: string): Promise<string[]>;
70
+ /**
71
+ * Find all daily logs that are not yet compressed (no `compressed: true`
72
+ * frontmatter). Returns an array of { date, file } for each uncompressed log.
73
+ */
74
+ export declare function findUncompressedDailies(teammateDir: string): Promise<{
75
+ date: string;
76
+ file: string;
77
+ }[]>;
78
+ /**
79
+ * Build a prompt for an agent to compress multiple daily logs in bulk.
80
+ * Used during version migrations to compress all historical daily logs.
81
+ * Returns null if there are no uncompressed dailies.
82
+ */
83
+ export declare function buildMigrationCompressionPrompt(_teammateDir: string, teammateName: string, dailies: {
84
+ date: string;
85
+ file: string;
86
+ }[]): Promise<string | null>;
87
+ /**
88
+ * Check if the previous day's log needs compression and return a prompt
89
+ * to compress it. Returns null if no compression is needed.
90
+ *
91
+ * A daily log is eligible for compression when:
92
+ * - Today's log does not yet exist (new day boundary)
93
+ * - Yesterday's log exists and is not already compressed (no `compressed: true` frontmatter)
94
+ */
95
+ export declare function buildDailyCompressionPrompt(teammateDir: string): Promise<{
96
+ date: string;
97
+ prompt: string;
98
+ } | null>;