@saadnvd1/agent-os 0.3.482 → 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.
package/README.md CHANGED
@@ -323,11 +323,11 @@ The ring in the tab bar (in the composer on a phone) is how full the
323
323
  conversation's context is, measured against the room before auto-compact:
324
324
  amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
325
325
 
326
- Chats and tasks compact at 150k tokens of context by default (Settings →
327
- Workspaces → Compact; off sets it to never), not near the model's window:
328
- AgentOS launches Claude Code with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to
329
- that size (its floor is 100k), so the agent compacts itself mid-turn. Between
330
- turns AgentOS catches what it didn't: a chat over the threshold is compacted
326
+ Two thresholds (Settings → Workspaces → Compact; 0 turns one off). Mid-turn,
327
+ the agent compacts itself at 300k by default: AgentOS launches Claude Code
328
+ with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to that size (its floor is 100k),
329
+ high enough that a long job isn't cut in half. Between
330
+ turns AgentOS catches what it didn't: a chat over its 150k threshold is compacted
331
331
  before the next message, and so is one idle past its prompt cache (5 minutes
332
332
  or an hour, read from the agent's own usage), whose next message would
333
333
  re-read the whole conversation at the cache-write price. A chip above the
@@ -339,6 +339,13 @@ shows one quiet line with the context before and after. An orchestrator
339
339
  isn't compacted (a summary would lose the fence around what it read from
340
340
  tasks and CI): it starts a fresh conversation from its brief and the current
341
341
  state at its fresh-start threshold, or earlier once idle past its cache.
342
+ What survives a compaction: the summary is asked to keep the task, scope
343
+ changes, every edited file by path, what's done, in progress and next, and
344
+ the decisions. Right after it, AgentOS hands back a note built from its own
345
+ records (a task's brief and scope changes, the files it edited, its todos,
346
+ the latest message and `git status`), which doesn't depend on what the
347
+ summary kept, and tells the agent not to re-read or rewrite whole files.
348
+ Terminal agents don't get the note yet.
342
349
  On a desktop browser the screen button attaches a screenshot of a screen or
343
350
  window you pick; on a phone, attach a screenshot as a photo.
344
351
 
@@ -349,8 +356,8 @@ turn ends. **Gate reviews** below it shows what the orchestrator's
349
356
  independent reviewer and scope check cost, by PR: they run as `claude -p`
350
357
  and leave no transcript, so each run's tokens, model and duration are read
351
358
  from its JSON result into `check_runs`, and the review's verdict says them
352
- too. Every compaction is kept in `chat_compactions` (who ran it, and the
353
- context before and after), so cost per task can be compared by date.
359
+ too. Every compaction is kept in `chat_compactions` (who ran it, whether it
360
+ fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
354
361
 
355
362
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
356
363
  menu, dialog or the command list is open (those close first). On a phone the
@@ -3,7 +3,8 @@ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
3
3
 
4
4
  // When a chat's conversation starts over (lib/chat/refresh): context
5
5
  // tokens for orchestrators and for tasks, and at which chats and tasks
6
- // compact (lib/chat/stale); 0 for never.
6
+ // compact: between turns (lib/chat/stale) and mid-turn, by the agent itself;
7
+ // 0 for never.
7
8
  export async function GET() {
8
9
  return NextResponse.json(refreshSettings());
9
10
  }
@@ -16,6 +17,7 @@ export async function PUT(request: NextRequest) {
16
17
  taskTokens: body.taskTokens,
17
18
  // Its name before it also set the agents' own auto-compact.
18
19
  compactTokens: body.compactTokens ?? body.staleCompactTokens,
20
+ autoCompactTokens: body.autoCompactTokens,
19
21
  });
20
22
  return NextResponse.json(refreshSettings());
21
23
  } catch (error) {
@@ -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
+ }
@@ -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).
@@ -91,10 +91,16 @@ export function recordTurn(
91
91
  return totals;
92
92
  }
93
93
 
94
+ // When a compaction ran: "mid_turn" is the agent's own, at its auto-compact
95
+ // window, while it worked; the rest happen between turns.
96
+ export type CompactionPhase = "mid_turn" | "between_turns";
97
+ export const compactionPhase = (trigger: string): CompactionPhase =>
98
+ trigger === "auto" ? "mid_turn" : "between_turns";
99
+
94
100
  // Records one compaction: who ran it ("auto" is the agent's own, at its
95
101
  // auto-compact window; "threshold" and "stale" are AgentOS's before a
96
- // message; "manual" is a /compact someone typed), and the context before
97
- // and after when the agent said.
102
+ // message; "manual" is a /compact someone typed), which phase that was,
103
+ // and the context before and after when the agent said.
98
104
  export function recordCompaction(
99
105
  session: Pick<Session, "id" | "name" | "workspace_id">,
100
106
  c: { trigger: string; before?: number; after?: number },
@@ -106,14 +112,15 @@ export function recordCompaction(
106
112
  | { name: string; workspace_id: string | null }
107
113
  | undefined;
108
114
  db.prepare(
109
- `INSERT INTO chat_compactions (session_id, session_name, workspace_id, at, trigger, before_tokens, after_tokens)
110
- VALUES (?, ?, ?, ?, ?, ?, ?)`
115
+ `INSERT INTO chat_compactions (session_id, session_name, workspace_id, at, trigger, phase, before_tokens, after_tokens)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
111
117
  ).run(
112
118
  session.id,
113
119
  current?.name ?? session.name,
114
120
  current?.workspace_id ?? session.workspace_id,
115
121
  at,
116
122
  c.trigger,
123
+ compactionPhase(c.trigger),
117
124
  c.before ?? null,
118
125
  c.after ?? null
119
126
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.482",
3
+ "version": "0.3.483",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"