@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-utils.js CHANGED
@@ -61,6 +61,80 @@ export const IMAGE_EXTS = new Set([
61
61
  ".svg",
62
62
  ".ico",
63
63
  ]);
64
+ /**
65
+ * Strip protocol artifacts (TO: header, handoff blocks, trailing JSON) from
66
+ * an agent's raw output, returning just the message body.
67
+ */
68
+ export function cleanResponseBody(rawOutput) {
69
+ return rawOutput
70
+ .replace(/^TO:\s*\S+\s*\n/im, "")
71
+ .replace(/```handoff\s*\n@\w+\s*\n[\s\S]*?```/g, "")
72
+ .replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/g, "")
73
+ .trim();
74
+ }
75
+ /**
76
+ * Format a conversation entry for inclusion in a prompt.
77
+ * Single-line text stays inline; multi-line text gets the body on the next line.
78
+ */
79
+ export function formatConversationEntry(role, text) {
80
+ return text.includes("\n")
81
+ ? `**${role}:**\n${text}\n`
82
+ : `**${role}:** ${text}\n`;
83
+ }
84
+ /**
85
+ * Build the conversation context section for a teammate prompt.
86
+ * Works backwards from newest entries, including whole entries up to the budget.
87
+ */
88
+ export function buildConversationContext(history, summary, budget) {
89
+ if (history.length === 0 && !summary)
90
+ return "";
91
+ const parts = ["## Conversation History\n"];
92
+ if (summary) {
93
+ parts.push(`### Previous Conversation Summary\n\n${summary}\n`);
94
+ }
95
+ const entries = [];
96
+ let used = 0;
97
+ for (let i = history.length - 1; i >= 0; i--) {
98
+ const entry = formatConversationEntry(history[i].role, history[i].text);
99
+ if (used + entry.length > budget && entries.length > 0)
100
+ break;
101
+ entries.unshift(entry);
102
+ used += entry.length;
103
+ }
104
+ if (entries.length > 0)
105
+ parts.push(entries.join("\n"));
106
+ return parts.join("\n");
107
+ }
108
+ /**
109
+ * Find the split index where older conversation entries should be summarized.
110
+ * Returns 0 if everything fits within the budget (nothing to summarize).
111
+ */
112
+ export function findSummarizationSplit(history, budget) {
113
+ let recentChars = 0;
114
+ let splitIdx = history.length;
115
+ for (let i = history.length - 1; i >= 0; i--) {
116
+ const entry = formatConversationEntry(history[i].role, history[i].text);
117
+ if (recentChars + entry.length > budget)
118
+ break;
119
+ recentChars += entry.length;
120
+ splitIdx = i;
121
+ }
122
+ return splitIdx === history.length ? 0 : splitIdx;
123
+ }
124
+ /**
125
+ * Build the summarization prompt text from entries being pushed out of the budget.
126
+ */
127
+ export function buildSummarizationPrompt(entries, existingSummary) {
128
+ const entriesText = entries
129
+ .map((e) => e.text.includes("\n")
130
+ ? `**${e.role}:**\n${e.text}`
131
+ : `**${e.role}:** ${e.text}`)
132
+ .join("\n\n");
133
+ const instructions = `## Instructions\n\nReturn ONLY the ${existingSummary ? "updated " : ""}summary — no preamble, no explanation. The summary should:\n- Be a concise bulleted list of key topics discussed, decisions made, and work completed\n- Preserve important context that future messages might reference\n- Drop trivial or redundant details\n- Stay under 2000 characters\n- Do NOT include any output protocol (no TO:, no # Subject, no handoff blocks)`;
134
+ return existingSummary
135
+ ? `You are maintaining a running summary of an ongoing conversation between a user and their AI teammates. Update the existing summary to incorporate the new conversation entries below.\n\n## Current Summary\n\n${existingSummary}\n\n## New Entries to Incorporate\n\n${entriesText}\n\n${instructions}`
136
+ : `You are maintaining a running summary of an ongoing conversation between a user and their AI teammates. Summarize the conversation entries below.\n\n## Entries to Summarize\n\n${entriesText}\n\n${instructions}`;
137
+ }
64
138
  /** Check if a string looks like an image file path. */
65
139
  export function isImagePath(text) {
66
140
  // Must look like a file path (contains slash or backslash, or starts with drive letter)
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { findAtMention, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
2
+ import { buildConversationContext, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, formatConversationEntry, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
3
3
  // ── relativeTime ────────────────────────────────────────────────────
4
4
  describe("relativeTime", () => {
5
5
  beforeEach(() => {
@@ -177,3 +177,187 @@ describe("IMAGE_EXTS", () => {
177
177
  expect(IMAGE_EXTS.size).toBe(8);
178
178
  });
179
179
  });
180
+ // ── cleanResponseBody ──────────────────────────────────────────────
181
+ describe("cleanResponseBody", () => {
182
+ it("strips TO: header", () => {
183
+ const raw = "TO: user\n# Subject\n\nBody text here";
184
+ expect(cleanResponseBody(raw)).toBe("# Subject\n\nBody text here");
185
+ });
186
+ it("strips TO: header case-insensitively", () => {
187
+ const raw = "to: User\n# Subject\n\nBody text here";
188
+ expect(cleanResponseBody(raw)).toBe("# Subject\n\nBody text here");
189
+ });
190
+ it("strips handoff blocks", () => {
191
+ const raw = "Some text\n\n```handoff\n@scribe\nDo this task\n```\n\nMore text";
192
+ expect(cleanResponseBody(raw)).toContain("Some text");
193
+ expect(cleanResponseBody(raw)).toContain("More text");
194
+ expect(cleanResponseBody(raw)).not.toContain("handoff");
195
+ expect(cleanResponseBody(raw)).not.toContain("@scribe");
196
+ });
197
+ it("strips multiple handoff blocks", () => {
198
+ const raw = "Text\n```handoff\n@scribe\ntask1\n```\nmiddle\n```handoff\n@beacon\ntask2\n```\nend";
199
+ expect(cleanResponseBody(raw)).toBe("Text\n\nmiddle\n\nend");
200
+ });
201
+ it("strips trailing JSON blocks", () => {
202
+ const raw = 'Body text\n\n```json\n{ "summary": "done" }\n```';
203
+ expect(cleanResponseBody(raw)).toBe("Body text");
204
+ });
205
+ it("strips all protocol artifacts together", () => {
206
+ const raw = 'TO: user\n# Done\n\nI finished the task.\n\n```handoff\n@pipeline\nDeploy it\n```\n\n```json\n{ "summary": "Done" }\n```';
207
+ expect(cleanResponseBody(raw)).toBe("# Done\n\nI finished the task.");
208
+ });
209
+ it("returns empty string for empty input", () => {
210
+ expect(cleanResponseBody("")).toBe("");
211
+ });
212
+ it("returns body unchanged when no protocol artifacts exist", () => {
213
+ expect(cleanResponseBody("Just a plain message")).toBe("Just a plain message");
214
+ });
215
+ it("trims surrounding whitespace", () => {
216
+ expect(cleanResponseBody(" \n Hello \n ")).toBe("Hello");
217
+ });
218
+ });
219
+ // ── formatConversationEntry ────────────────────────────────────────
220
+ describe("formatConversationEntry", () => {
221
+ it("formats single-line text inline", () => {
222
+ expect(formatConversationEntry("scribe", "Task completed")).toBe("**scribe:** Task completed\n");
223
+ });
224
+ it("formats multi-line text with body on next line", () => {
225
+ expect(formatConversationEntry("beacon", "Line 1\nLine 2")).toBe("**beacon:**\nLine 1\nLine 2\n");
226
+ });
227
+ it("treats single line with no newline as inline", () => {
228
+ expect(formatConversationEntry("user", "hello")).toBe("**user:** hello\n");
229
+ });
230
+ });
231
+ // ── buildConversationContext ────────────────────────────────────────
232
+ describe("buildConversationContext", () => {
233
+ it("returns empty string for empty history and no summary", () => {
234
+ expect(buildConversationContext([], "", 1000)).toBe("");
235
+ });
236
+ it("includes summary when present", () => {
237
+ const result = buildConversationContext([], "Previous topics discussed", 1000);
238
+ expect(result).toContain("## Conversation History");
239
+ expect(result).toContain("### Previous Conversation Summary");
240
+ expect(result).toContain("Previous topics discussed");
241
+ });
242
+ it("includes all entries when within budget", () => {
243
+ const history = [
244
+ { role: "stevenic", text: "Hello" },
245
+ { role: "scribe", text: "Hi there" },
246
+ { role: "stevenic", text: "Do the thing" },
247
+ ];
248
+ const result = buildConversationContext(history, "", 10_000);
249
+ expect(result).toContain("**stevenic:** Hello");
250
+ expect(result).toContain("**scribe:** Hi there");
251
+ expect(result).toContain("**stevenic:** Do the thing");
252
+ });
253
+ it("drops oldest entries when over budget", () => {
254
+ const history = [
255
+ { role: "old", text: "A".repeat(500) },
256
+ { role: "mid", text: "B".repeat(500) },
257
+ { role: "new", text: "C".repeat(500) },
258
+ ];
259
+ // Budget enough for ~2 entries but not 3
260
+ const budget = 1100;
261
+ const result = buildConversationContext(history, "", budget);
262
+ expect(result).not.toContain("**old:**");
263
+ expect(result).toContain("**new:**");
264
+ });
265
+ it("always includes at least the newest entry even if over budget", () => {
266
+ const history = [
267
+ { role: "beacon", text: "A".repeat(2000) },
268
+ ];
269
+ const result = buildConversationContext(history, "", 100);
270
+ expect(result).toContain("**beacon:**");
271
+ });
272
+ it("formats multi-line entries with body on next line", () => {
273
+ const history = [
274
+ { role: "scribe", text: "Line 1\nLine 2\nLine 3" },
275
+ ];
276
+ const result = buildConversationContext(history, "", 10_000);
277
+ expect(result).toContain("**scribe:**\nLine 1\nLine 2\nLine 3");
278
+ });
279
+ it("includes both summary and entries", () => {
280
+ const history = [
281
+ { role: "stevenic", text: "Latest message" },
282
+ ];
283
+ const result = buildConversationContext(history, "Earlier we discussed X", 10_000);
284
+ expect(result).toContain("### Previous Conversation Summary");
285
+ expect(result).toContain("Earlier we discussed X");
286
+ expect(result).toContain("**stevenic:** Latest message");
287
+ });
288
+ });
289
+ // ── findSummarizationSplit ─────────────────────────────────────────
290
+ describe("findSummarizationSplit", () => {
291
+ it("returns 0 when everything fits in budget", () => {
292
+ const history = [
293
+ { role: "a", text: "short" },
294
+ { role: "b", text: "also short" },
295
+ ];
296
+ expect(findSummarizationSplit(history, 10_000)).toBe(0);
297
+ });
298
+ it("returns 0 for empty history", () => {
299
+ expect(findSummarizationSplit([], 1000)).toBe(0);
300
+ });
301
+ it("returns split index when history exceeds budget", () => {
302
+ const history = [
303
+ { role: "old1", text: "A".repeat(400) },
304
+ { role: "old2", text: "B".repeat(400) },
305
+ { role: "new1", text: "C".repeat(400) },
306
+ { role: "new2", text: "D".repeat(400) },
307
+ ];
308
+ // Budget fits ~2 entries (~430 chars each with formatting)
309
+ const budget = 900;
310
+ const split = findSummarizationSplit(history, budget);
311
+ expect(split).toBeGreaterThan(0);
312
+ expect(split).toBeLessThan(history.length);
313
+ });
314
+ it("keeps newest entries and pushes oldest out", () => {
315
+ const history = [
316
+ { role: "oldest", text: "X".repeat(300) },
317
+ { role: "middle", text: "Y".repeat(300) },
318
+ { role: "newest", text: "Z".repeat(300) },
319
+ ];
320
+ // Budget fits 1 entry
321
+ const budget = 350;
322
+ const split = findSummarizationSplit(history, budget);
323
+ // Split should be 2 — entries 0 and 1 get summarized, entry 2 (newest) stays
324
+ expect(split).toBe(2);
325
+ });
326
+ it("returns 0 when single entry fits", () => {
327
+ const history = [{ role: "a", text: "hello" }];
328
+ expect(findSummarizationSplit(history, 10_000)).toBe(0);
329
+ });
330
+ });
331
+ // ── buildSummarizationPrompt ───────────────────────────────────────
332
+ describe("buildSummarizationPrompt", () => {
333
+ const entries = [
334
+ { role: "stevenic", text: "Build the feature" },
335
+ { role: "beacon", text: "Done, here's what I did" },
336
+ ];
337
+ it("builds a fresh summarization prompt when no existing summary", () => {
338
+ const prompt = buildSummarizationPrompt(entries, "");
339
+ expect(prompt).toContain("Summarize the conversation entries below");
340
+ expect(prompt).toContain("**stevenic:** Build the feature");
341
+ expect(prompt).toContain("**beacon:** Done, here's what I did");
342
+ expect(prompt).not.toContain("Current Summary");
343
+ });
344
+ it("builds an update prompt when existing summary is present", () => {
345
+ const prompt = buildSummarizationPrompt(entries, "Previously discussed X");
346
+ expect(prompt).toContain("Update the existing summary");
347
+ expect(prompt).toContain("## Current Summary");
348
+ expect(prompt).toContain("Previously discussed X");
349
+ expect(prompt).toContain("## New Entries to Incorporate");
350
+ });
351
+ it("includes instruction constraints", () => {
352
+ const prompt = buildSummarizationPrompt(entries, "");
353
+ expect(prompt).toContain("Stay under 2000 characters");
354
+ expect(prompt).toContain("Do NOT include any output protocol");
355
+ });
356
+ it("formats multi-line entries correctly", () => {
357
+ const multiLine = [
358
+ { role: "scribe", text: "Line 1\nLine 2" },
359
+ ];
360
+ const prompt = buildSummarizationPrompt(multiLine, "");
361
+ expect(prompt).toContain("**scribe:**\nLine 1\nLine 2");
362
+ });
363
+ });