@saadnvd1/agent-os 0.3.481 → 0.3.483

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.
@@ -0,0 +1,146 @@
1
+ import "./composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { ChatItem } from "@/lib/chat/events";
6
+ import { groupTimeline } from "@/lib/chat/group";
7
+ import { copySelection } from "./copy-selection";
8
+ import { TimelineRows } from "./ChatPanel";
9
+
10
+ (
11
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
12
+ ).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const user = (id: string, text: string): ChatItem => ({
15
+ id,
16
+ kind: "user",
17
+ createdAt: 1,
18
+ text,
19
+ tagged: true,
20
+ });
21
+ const reply = (id: string, text: string, streaming = false): ChatItem => ({
22
+ id,
23
+ kind: "assistant",
24
+ createdAt: 2,
25
+ text,
26
+ streaming,
27
+ });
28
+
29
+ describe("the conversation's rows", () => {
30
+ let host: HTMLElement;
31
+ let root: Root;
32
+ const actions = () => ({ respond: vi.fn() }) as never;
33
+ const show = (items: ChatItem[], a = actions()) =>
34
+ act(() =>
35
+ root.render(
36
+ createElement(TimelineRows, {
37
+ blocks: groupTimeline(items),
38
+ actions: a,
39
+ })
40
+ )
41
+ );
42
+ const rows = () => [...host.querySelectorAll("[data-block]")];
43
+
44
+ beforeEach(() => {
45
+ host = document.createElement("div");
46
+ document.body.append(host);
47
+ root = createRoot(host);
48
+ });
49
+ afterEach(() => {
50
+ act(() => root.unmount());
51
+ host.remove();
52
+ document.getSelection()?.removeAllRanges();
53
+ });
54
+
55
+ it("keeps a selection across messages through streaming updates", async () => {
56
+ const items = [
57
+ user("u1", "first"),
58
+ reply("a1", "second", true),
59
+ user("u2", "third"),
60
+ ];
61
+ await show(items);
62
+ const [r1, , r3] = rows();
63
+ const range = document.createRange();
64
+ range.setStart(r1, 0);
65
+ range.setEnd(r3, r3.childNodes.length);
66
+ document.getSelection()!.removeAllRanges();
67
+ document.getSelection()!.addRange(range);
68
+
69
+ await show([
70
+ items[0],
71
+ reply("a1", "second, and more words streamed in", true),
72
+ items[2],
73
+ reply("a2", "a new reply at the end", true),
74
+ ]);
75
+ await show(
76
+ [
77
+ items[0],
78
+ reply("a1", "second, and more words streamed in", false),
79
+ items[2],
80
+ reply("a2", "a new reply at the end, done", false),
81
+ ],
82
+ actions()
83
+ );
84
+
85
+ const after = rows();
86
+ expect(after).toHaveLength(4);
87
+ expect(after[0]).toBe(r1);
88
+ expect(after[2]).toBe(r3);
89
+ expect(r1.isConnected && r3.isConnected).toBe(true);
90
+ expect(after.map((r) => r.getAttribute("data-block"))).toEqual([
91
+ "u1",
92
+ "a1",
93
+ "u2",
94
+ "a2",
95
+ ]);
96
+ const sel = document.getSelection()!;
97
+ expect(sel.rangeCount).toBe(1);
98
+ expect(sel.isCollapsed).toBe(false);
99
+ expect(sel.getRangeAt(0).startContainer).toBe(r1);
100
+ expect(sel.getRangeAt(0).endContainer).toBe(r3);
101
+ });
102
+
103
+ describe("copying", () => {
104
+ const copy = (items: ChatItem[]) => {
105
+ const data: Record<string, string> = {};
106
+ const e = {
107
+ currentTarget: host,
108
+ clipboardData: { setData: (t: string, v: string) => (data[t] = v) },
109
+ preventDefault: vi.fn(),
110
+ };
111
+ copySelection(e as never, new Map(items.map((i) => [i.id, i])));
112
+ return { data, prevented: e.preventDefault.mock.calls.length > 0 };
113
+ };
114
+
115
+ it("gives speakers and blank lines across messages, from their source", async () => {
116
+ const items = [
117
+ user("u1", "fix `the` bug"),
118
+ reply("a1", "Done.\n\n- one\n- two"),
119
+ user("u2", "thanks"),
120
+ ];
121
+ await show(items);
122
+ const [r1, , r3] = rows();
123
+ const range = document.createRange();
124
+ range.selectNodeContents(host);
125
+ range.setStart(r1, 0);
126
+ range.setEnd(r3, r3.childNodes.length);
127
+ document.getSelection()!.addRange(range);
128
+ const { data, prevented } = copy(items);
129
+ expect(prevented).toBe(true);
130
+ expect(data["text/plain"]).toBe(
131
+ "User:\nfix `the` bug\n\nAssistant:\nDone.\n\n- one\n- two\n\nUser:\nthanks"
132
+ );
133
+ });
134
+
135
+ it("leaves a selection inside one message to the browser", async () => {
136
+ const items = [user("u1", "first"), reply("a1", "second words")];
137
+ await show(items);
138
+ const text = rows()[1].querySelector("p")!.firstChild!;
139
+ const range = document.createRange();
140
+ range.setStart(text, 1);
141
+ range.setEnd(text, 4);
142
+ document.getSelection()!.addRange(range);
143
+ expect(copy(items)).toEqual({ data: {}, prevented: false });
144
+ });
145
+ });
146
+ });
@@ -19,7 +19,7 @@ export function FreshStartForm() {
19
19
  return <p className="text-destructive text-sm">{error.message}</p>;
20
20
  return (
21
21
  <Form
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
22
+ key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}`}
23
23
  saved={data}
24
24
  />
25
25
  );
@@ -30,12 +30,15 @@ function Form({ saved }: { saved: RefreshSettings }) {
30
30
  const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
31
  const task = useThreshold(saved.taskTokens, 200);
32
32
  const compact = useThreshold(saved.compactTokens, 150);
33
- const error = orchestrator.error ?? task.error ?? compact.error;
33
+ const midTurn = useThreshold(saved.autoCompactTokens, 300);
34
+ const error =
35
+ orchestrator.error ?? task.error ?? compact.error ?? midTurn.error;
34
36
  const dirty =
35
37
  !error &&
36
38
  (orchestrator.tokens !== saved.orchestratorTokens ||
37
39
  task.tokens !== saved.taskTokens ||
38
- compact.tokens !== saved.compactTokens);
40
+ compact.tokens !== saved.compactTokens ||
41
+ midTurn.tokens !== saved.autoCompactTokens);
39
42
 
40
43
  return (
41
44
  <form
@@ -47,6 +50,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
47
50
  orchestratorTokens: orchestrator.tokens,
48
51
  taskTokens: task.tokens,
49
52
  compactTokens: compact.tokens,
53
+ autoCompactTokens: midTurn.tokens,
50
54
  },
51
55
  {
52
56
  onSuccess: () => toast.success("Context settings saved"),
@@ -67,15 +71,23 @@ function Form({ saved }: { saved: RefreshSettings }) {
67
71
  <ThresholdRow label="Tasks" field={task} />
68
72
  <h3 className="pt-2 text-sm font-medium">Compact</h3>
69
73
  <p className="text-muted-foreground text-sm">
70
- Chats and tasks compact when their context passes this size: Claude Code
71
- does it itself, and between turns AgentOS compacts a chat over it, or
72
- idle past its prompt cache, before the next message. The composer offers
73
- to first. Orchestrators start fresh instead.
74
+ Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
75
+ own context when a job runs long: set this high, since it happens while
76
+ the agent is holding its files. Between turns, nothing is in flight, so
77
+ AgentOS compacts a chat over the smaller size, or idle past its prompt
78
+ cache, before the next message; the composer offers to first. Either way
79
+ AgentOS hands the agent its task, scope changes, files and todos back.
80
+ Orchestrators start fresh instead.
74
81
  </p>
75
82
  <ThresholdRow
76
- label="Chats and tasks"
83
+ label="Mid-turn"
84
+ field={midTurn}
85
+ switchLabel="Let the agent compact itself mid-turn"
86
+ />
87
+ <ThresholdRow
88
+ label="Between turns"
77
89
  field={compact}
78
- switchLabel="Compact chats and tasks at this size"
90
+ switchLabel="Compact chats and tasks between turns"
79
91
  />
80
92
  {error && <p className="text-destructive text-xs">{error}</p>}
81
93
  <div className="flex justify-end">
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The note an agent gets back right after a compaction, gathered from
3
+ * AgentOS's records (lib/chat/working-state has the rules). From the
4
+ * database each time rather than tracked in memory, so it is right after a
5
+ * deploy restarted the worker too.
6
+ */
7
+
8
+ import os from "node:os";
9
+ import { statusPorcelain } from "../git-exec";
10
+ import { db, type Session } from "../db";
11
+ import { amendmentsOf } from "../orchestrator/amendments";
12
+ import { lastItems } from "./store";
13
+ import { workingStateNote } from "./working-state";
14
+
15
+ async function gitStatus(cwd: string): Promise<string> {
16
+ try {
17
+ const out = await statusPorcelain(cwd, {
18
+ timeout: 3_000,
19
+ maxBuffer: 64 * 1024,
20
+ });
21
+ return out.split("\n").slice(0, 40).join("\n");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ // Empty when the session is gone or there is nothing to say.
28
+ export async function compactContext(sessionId: string): Promise<string> {
29
+ const session = db
30
+ .prepare(
31
+ `SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
32
+ )
33
+ .get(sessionId) as
34
+ | Pick<Session, "working_directory" | "task_prompt" | "role">
35
+ | undefined;
36
+ // An orchestrator starts fresh instead of compacting.
37
+ if (!session || session.role === "orchestrator") return "";
38
+ const tools = lastItems(sessionId, 400, "tool").filter(
39
+ (i) => i.kind === "tool"
40
+ );
41
+ const todos = lastItems(sessionId, 1, "todos").find(
42
+ (i) => i.kind === "todos"
43
+ );
44
+ const user = lastItems(sessionId, 1, "user").find((i) => i.kind === "user");
45
+ return workingStateNote({
46
+ taskPrompt: session.task_prompt,
47
+ amendments: amendmentsOf(sessionId).map((a) => a.text),
48
+ tools,
49
+ todos: todos?.todos,
50
+ requests: user ? [user.text] : [],
51
+ gitStatus: await gitStatus(
52
+ session.working_directory.replace(/^~/, os.homedir())
53
+ ),
54
+ });
55
+ }
@@ -0,0 +1,58 @@
1
+ import type { ChatItem } from "./events";
2
+ import { eventParts, originOf } from "./origin";
3
+
4
+ export interface TranscriptPart {
5
+ speaker: string;
6
+ text: string;
7
+ }
8
+
9
+ // Who a message is from when copied, or null for rows that aren't part of the
10
+ // conversation itself (tools, turn markers, plans, approvals...).
11
+ export function speakerOf(item: ChatItem): string | null {
12
+ switch (item.kind) {
13
+ case "user": {
14
+ const origin = originOf(item);
15
+ if (!origin) return "User";
16
+ return origin.kind === "decision" ? "Decision" : origin.label;
17
+ }
18
+ case "assistant":
19
+ return "Assistant";
20
+ case "error":
21
+ return "Error";
22
+ default:
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // The whole message as written, for one a selection covers entirely.
28
+ export function sourceText(item: ChatItem): string {
29
+ switch (item.kind) {
30
+ case "user": {
31
+ const origin = originOf(item);
32
+ if (!origin) return item.text;
33
+ const { body, decided } = eventParts(item, origin);
34
+ return [...decided, body].join("\n\n");
35
+ }
36
+ case "assistant":
37
+ return item.text;
38
+ case "error":
39
+ return item.message;
40
+ default:
41
+ return "";
42
+ }
43
+ }
44
+
45
+ // `Speaker:` then the text, a blank line between messages. Neighbours from
46
+ // the same speaker (a reply split around its tools) read as one message.
47
+ export function formatTranscript(parts: TranscriptPart[]): string {
48
+ const merged: TranscriptPart[] = [];
49
+ for (const p of parts) {
50
+ const text = p.text.trim();
51
+ if (!text) continue;
52
+ const last = merged.at(-1);
53
+ if (last && last.speaker === p.speaker)
54
+ last.text = `${last.text}\n\n${text}`;
55
+ else merged.push({ speaker: p.speaker, text });
56
+ }
57
+ return merged.map((p) => `${p.speaker}:\n${p.text}`).join("\n\n");
58
+ }
@@ -41,6 +41,10 @@ export interface ChatStartOptions {
41
41
  // Checked before every tool call, whatever the permissions say: why it's
42
42
  // refused, or null to let it go on to them.
43
43
  toolGuard?: ToolGuard;
44
+ // The note handed back to the agent right after its context is compacted
45
+ // (lib/chat/compact-context), empty for none. Only Claude Code has a hook
46
+ // for it; other drivers ignore it.
47
+ compactContext?: () => Promise<string>;
44
48
  }
45
49
 
46
50
  export type ToolGuard = (
@@ -275,3 +275,35 @@ export function preToolUseHooks(
275
275
  ...(guard ? [{ hooks: [guardHook(guard)] }] : []),
276
276
  ];
277
277
  }
278
+
279
+ // After a compaction Claude Code starts the session again with source
280
+ // "compact", and what a SessionStart hook returns as additionalContext is
281
+ // added to the context: the one way to hand the agent something back that
282
+ // doesn't depend on what its summary kept. A failure here never blocks it.
283
+ export function compactedHooks(
284
+ context: () => Promise<string>
285
+ ): HookCallbackMatcher[] {
286
+ return [
287
+ {
288
+ matcher: "compact",
289
+ hooks: [
290
+ async () => {
291
+ try {
292
+ const additionalContext = await context();
293
+ return additionalContext
294
+ ? {
295
+ hookSpecificOutput: {
296
+ hookEventName: "SessionStart",
297
+ additionalContext,
298
+ },
299
+ }
300
+ : {};
301
+ } catch (e) {
302
+ console.error("building the compaction note:", e);
303
+ return {};
304
+ }
305
+ },
306
+ ],
307
+ },
308
+ ];
309
+ }
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
10
10
  import { InputQueue } from "../queue";
11
11
  import {
12
12
  Approvals,
13
+ compactedHooks,
13
14
  isPlanFile,
14
15
  preToolUseHooks,
15
16
  sdkMode,
@@ -193,6 +194,9 @@ export const claudeDriver: ChatDriver = {
193
194
  disallowedTools: options.disallowedTools,
194
195
  hooks: {
195
196
  PreToolUse: preToolUseHooks(approvals, options.toolGuard),
197
+ ...(options.compactContext && {
198
+ SessionStart: compactedHooks(options.compactContext),
199
+ }),
196
200
  },
197
201
  settings: { attribution: NO_ATTRIBUTION },
198
202
  enableFileCheckpointing: true,
@@ -6,8 +6,9 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the size at which chats and
10
- * tasks compact (lib/chat/stale).
9
+ * App-wide, in Settings → Workspaces, with the two sizes at which chats and
10
+ * tasks compact: mid-turn (Claude Code itself, high so a job keeps its
11
+ * files) and between turns (lib/chat/stale, earlier, nothing in flight).
11
12
  */
12
13
 
13
14
  import { db } from "../db";
@@ -20,16 +21,22 @@ export const TASK_KEY = "chat.task_refresh_tokens";
20
21
  export const COMPACT_KEY = "chat.stale_compact_tokens";
21
22
  export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
22
23
  export const DEFAULT_COMPACT_TOKENS = 150_000;
24
+ // Claude Code's own mid-turn auto-compact window: high, because a summary
25
+ // mid-job costs the agent its files and its place.
26
+ export const AUTO_COMPACT_KEY = "chat.auto_compact_tokens";
27
+ export const DEFAULT_AUTO_COMPACT_TOKENS = 300_000;
23
28
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
24
29
 
25
30
  export interface RefreshSettings {
26
31
  // Context tokens at which a conversation starts over; 0 is off.
27
32
  orchestratorTokens: number;
28
33
  taskTokens: number;
29
- // Context tokens at which a chat's or task's agent compacts: Claude
30
- // Code's own auto-compact window, and between turns a chat over it (or
31
- // idle past its cache) is compacted before the next message. 0 is off.
34
+ // Between turns: a chat over this size (or idle past its cache) is
35
+ // compacted before its next message. 0 is off.
32
36
  compactTokens: number;
37
+ // Mid-turn: Claude Code's own auto-compact window for a chat's or task's
38
+ // agent. 0 is off.
39
+ autoCompactTokens: number;
33
40
  }
34
41
 
35
42
  function read(key: string, fallback: number): number {
@@ -45,6 +52,7 @@ export function refreshSettings(): RefreshSettings {
45
52
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
46
53
  taskTokens: read(TASK_KEY, 0),
47
54
  compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
55
+ autoCompactTokens: read(AUTO_COMPACT_KEY, DEFAULT_AUTO_COMPACT_TOKENS),
48
56
  };
49
57
  }
50
58
 
@@ -79,6 +87,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
79
87
  checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
80
88
  if (input.compactTokens !== undefined)
81
89
  checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
90
+ if (input.autoCompactTokens !== undefined)
91
+ checked.push([
92
+ AUTO_COMPACT_KEY,
93
+ checkRefreshTokens(input.autoCompactTokens),
94
+ ]);
82
95
  db.transaction(() => {
83
96
  for (const [key, n] of checked) put.run(key, String(n));
84
97
  })();
@@ -119,7 +132,7 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
119
132
  .get(sessionId) as
120
133
  | { role: string | null; model_provider: string | null }
121
134
  | undefined;
122
- const tokens = refreshSettings().compactTokens;
135
+ const tokens = refreshSettings().autoCompactTokens;
123
136
  if (row?.role === "orchestrator" || tokens <= 0) return {};
124
137
  const window = compactThreshold(
125
138
  Math.max(AUTO_COMPACT_MIN, tokens),
package/lib/chat/stale.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Compacting between turns. A chat's agent compacts itself at the compact
3
- * threshold (Claude Code's auto-compact window, set at launch by
4
- * lib/agents/launch). Between turns AgentOS catches what it didn't: a chat
5
- * over the threshold is compacted before the next message, and so is one
2
+ * Compacting between turns. A chat's agent compacts itself mid-turn at its
3
+ * own, higher window (Claude Code's auto-compact, set at launch by
4
+ * lib/agents/launch). Between turns nothing is in flight, so AgentOS
5
+ * compacts earlier: a chat over the compact threshold is compacted before
6
+ * the next message, and so is one
6
7
  * whose prompt cache has expired, since that message would re-read the
7
8
  * whole history at the cache-write price (one summary turn, then a small
8
9
  * cache write instead of a huge one). The one check every send path uses:
@@ -41,6 +41,8 @@ import {
41
41
  } from "../../artifacts/tools";
42
42
  import type { RoleExtras } from "./extras";
43
43
  import { compactThreshold, refreshSettings, refreshedNote } from "../refresh";
44
+ import { compactContext } from "../compact-context";
45
+ import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
44
46
  import { compactionDue, staleLabel, type Staleness } from "../stale";
45
47
  import type { WorkerCommand, WorkerEvent } from "./protocol";
46
48
 
@@ -164,6 +166,7 @@ export class ChatHost {
164
166
  this.atRest = driver.atRest !== false;
165
167
  const cwd = session.working_directory.replace(/^~/, os.homedir());
166
168
  const env = { ...agentEnv(session.id), ...extras.env };
169
+ const orchestrator = session.role === "orchestrator";
167
170
  // Every chat can show visuals, asked for like any tool under its access
168
171
  // setting (they read files and reach the web). Not an orchestrator: it
169
172
  // reads other sessions' text, and a browser would be a way out for it.
@@ -196,6 +199,7 @@ export class ChatHost {
196
199
  sessionRunningBrief(session.id),
197
200
  BUS_BRIEF,
198
201
  visuals && VISUALS_BRIEF,
202
+ !orchestrator && COMPACT_BRIEF,
199
203
  ]
200
204
  .filter(Boolean)
201
205
  .join("\n\n"),
@@ -211,6 +215,10 @@ export class ChatHost {
211
215
  disallowedTools: extras.disallowedTools,
212
216
  permissionMode: extras.permissionMode,
213
217
  toolGuard: extras.toolGuard,
218
+ // Not an orchestrator: it starts a fresh conversation instead.
219
+ compactContext: orchestrator
220
+ ? undefined
221
+ : () => compactContext(session.id),
214
222
  });
215
223
  this.suggestion = session.chat_suggestion ?? null;
216
224
  try {
@@ -725,7 +733,7 @@ export class ChatHost {
725
733
  };
726
734
  this.currentTurn = null;
727
735
  this.stopping = null;
728
- this.conversation.send("/compact");
736
+ this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
729
737
  this.setState("running");
730
738
  console.error(
731
739
  `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What an agent needs to keep its place across a compaction. Claude Code's
3
+ * summary decides for itself what mattered, and a long job's task, its scope
4
+ * changes and the files it was editing are exactly what it dropped: the
5
+ * agent re-read them and then rewrote whole files to be sure. So the summary
6
+ * is told what to keep (COMPACT_INSTRUCTIONS), and right after it AgentOS
7
+ * hands back a note built from its own records (workingStateNote), which
8
+ * doesn't depend on what the summary kept.
9
+ *
10
+ * Pure, so the rules are tested without a database (lib/chat/compact-context
11
+ * gathers the inputs).
12
+ */
13
+
14
+ import type { ChatItem } from "./events";
15
+
16
+ export const COMPACT_INSTRUCTIONS = [
17
+ "Keep, verbatim where short: the task or request you are working on and any changes to its scope.",
18
+ "List every file you created or edited, by absolute path, and what you changed in each.",
19
+ "State what is done, what is in progress, and the exact next step.",
20
+ "Keep the decisions made and why, and any commands or checks still to run.",
21
+ "After the summary, do not re-read whole files you were editing: search for the spot and edit it in place, and never rewrite a whole large file to change a part of it.",
22
+ ].join(" ");
23
+
24
+ // In the system prompt, which the summary request reuses and a summary
25
+ // can't drop.
26
+ export const COMPACT_BRIEF = `If your context is compacted, the summary must: ${COMPACT_INSTRUCTIONS}`;
27
+
28
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
29
+ const MAX_FILES = 20;
30
+ const MAX_PROMPT = 8_000;
31
+ const MAX_REQUEST = 1_500;
32
+ const MAX_NOTE = 12_000;
33
+
34
+ type Todo = Extract<ChatItem, { kind: "todos" }>["todos"][number];
35
+
36
+ export interface WorkingState {
37
+ // The task's own prompt, when it is one, and its scope changes (oldest
38
+ // first).
39
+ taskPrompt?: string | null;
40
+ amendments?: string[];
41
+ // Tool calls and the latest todos and user messages, oldest first.
42
+ tools?: Extract<ChatItem, { kind: "tool" }>[];
43
+ todos?: Todo[];
44
+ requests?: string[];
45
+ // `git status --short`, when the directory is a repository.
46
+ gitStatus?: string;
47
+ }
48
+
49
+ const cut = (text: string, max: number) =>
50
+ text.length > max ? `${text.slice(0, max)}\n… (cut)` : text;
51
+
52
+ // The path a file-changing tool call is about, newest first, each once.
53
+ export function editedFiles(
54
+ tools: Extract<ChatItem, { kind: "tool" }>[]
55
+ ): string[] {
56
+ const seen = new Set<string>();
57
+ for (const t of [...tools].reverse()) {
58
+ if (!EDIT_TOOLS.has(t.name) || t.status === "error") continue;
59
+ const input = (t.input ?? {}) as Record<string, unknown>;
60
+ const path = input.file_path ?? input.notebook_path ?? t.diff?.path;
61
+ if (typeof path === "string" && path) seen.add(path);
62
+ if (seen.size >= MAX_FILES) break;
63
+ }
64
+ return [...seen];
65
+ }
66
+
67
+ const MARK: Record<Todo["status"], string> = {
68
+ completed: "done",
69
+ in_progress: "in progress",
70
+ pending: "next",
71
+ };
72
+
73
+ export function workingStateNote(s: WorkingState): string {
74
+ const parts: string[] = [];
75
+ const prompt = s.taskPrompt?.trim();
76
+ if (prompt) parts.push(`## Your task\n${cut(prompt, MAX_PROMPT)}`);
77
+ const amendments = (s.amendments ?? []).filter((a) => a.trim());
78
+ if (amendments.length)
79
+ parts.push(
80
+ `## Scope changes since it started (oldest first; they change the task above)\n${amendments
81
+ .map((a, i) => `${i + 1}. ${cut(a.trim(), MAX_REQUEST)}`)
82
+ .join("\n")}`
83
+ );
84
+ const files = editedFiles(s.tools ?? []);
85
+ if (files.length)
86
+ parts.push(
87
+ `## Files you were editing (newest first)\n${files.map((f) => `- ${f}`).join("\n")}`
88
+ );
89
+ if (s.todos?.length)
90
+ parts.push(
91
+ `## Your todo list\n${s.todos.map((t) => `- [${MARK[t.status]}] ${t.text}`).join("\n")}`
92
+ );
93
+ const requests = (s.requests ?? [])
94
+ .map((r) => r.trim())
95
+ // The task's own prompt is already above.
96
+ .filter((r) => r && !(prompt && r.startsWith(prompt.slice(0, 200))));
97
+ if (requests.length)
98
+ parts.push(
99
+ `## Latest from the reader\n${requests.map((r) => cut(r, MAX_REQUEST)).join("\n\n")}`
100
+ );
101
+ if (s.gitStatus?.trim())
102
+ parts.push(`## git status\n${cut(s.gitStatus.trim(), 1_500)}`);
103
+ if (!parts.length) return "";
104
+ parts.push(
105
+ "This note is from AgentOS, rebuilt from its own records after the context was compacted; the summary has the rest. Carry on from the next step. Don't re-read whole files you were editing (search for the spot), and edit in place rather than rewriting a whole file."
106
+ );
107
+ return cut(
108
+ `(Your context was just compacted. Where you were:)\n\n${parts.join("\n\n")}`,
109
+ MAX_NOTE
110
+ );
111
+ }
@@ -1459,6 +1459,19 @@ const migrations: Migration[] = [
1459
1459
  );
1460
1460
  })(),
1461
1461
  },
1462
+ {
1463
+ id: 64,
1464
+ name: "chat_compactions_phase",
1465
+ up: (db) => {
1466
+ // Whether the agent compacted itself mid-turn or AgentOS (or a typed
1467
+ // /compact) did between turns; null on rows from before.
1468
+ const columns = db
1469
+ .prepare(`PRAGMA table_info(chat_compactions)`)
1470
+ .all() as { name: string }[];
1471
+ if (!columns.some((c) => c.name === "phase"))
1472
+ db.exec(`ALTER TABLE chat_compactions ADD COLUMN phase TEXT`);
1473
+ },
1474
+ },
1462
1475
  ];
1463
1476
 
1464
1477
  // `upTo`: stop after this id (tests that start from an older database).