@saadnvd1/agent-os 0.3.499 → 0.3.502
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 +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +72 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
package/data/chat/useChat.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ChatModel,
|
|
14
14
|
ChatServerMessage,
|
|
15
15
|
ChatState,
|
|
16
|
+
Compacting,
|
|
16
17
|
FileSuggestion,
|
|
17
18
|
QueuedMessage,
|
|
18
19
|
ToolBody,
|
|
@@ -138,6 +139,7 @@ export function useChat(
|
|
|
138
139
|
return () => void visibleBodies.current.delete(id);
|
|
139
140
|
}, []);
|
|
140
141
|
const [state, setState] = useState<ChatState>("idle");
|
|
142
|
+
const [compacting, setCompacting] = useState<Compacting | null>(null);
|
|
141
143
|
const [connected, setConnected] = useState(false);
|
|
142
144
|
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
143
145
|
const [models, setModels] = useState<ChatModel[]>([]);
|
|
@@ -218,6 +220,7 @@ export function useChat(
|
|
|
218
220
|
setToolBodies((prev) => keptBodies(prev, before, m.items));
|
|
219
221
|
setLoadingOlder(false);
|
|
220
222
|
setState(m.state);
|
|
223
|
+
setCompacting(m.compacting ?? null);
|
|
221
224
|
setQueue(m.queue ?? []);
|
|
222
225
|
setSuggestion(m.suggestion ?? null);
|
|
223
226
|
} else if (m.type === "queue") {
|
|
@@ -230,8 +233,11 @@ export function useChat(
|
|
|
230
233
|
} else if (m.type === "search") {
|
|
231
234
|
searchRequests.current.get(m.reqId)?.(m);
|
|
232
235
|
searchRequests.current.delete(m.reqId);
|
|
236
|
+
} else if (m.type === "compacting") {
|
|
237
|
+
setCompacting(m.compacting);
|
|
233
238
|
} else if (m.type === "state") {
|
|
234
239
|
setState(m.state);
|
|
240
|
+
if (m.state === "idle" || m.state === "error") setCompacting(null);
|
|
235
241
|
} else if (m.type === "capabilities") {
|
|
236
242
|
setRefreshingCommands(false);
|
|
237
243
|
setCommands(m.commands);
|
|
@@ -469,6 +475,7 @@ export function useChat(
|
|
|
469
475
|
requestToolBody,
|
|
470
476
|
pinToolBody,
|
|
471
477
|
state,
|
|
478
|
+
compacting,
|
|
472
479
|
connected,
|
|
473
480
|
commands,
|
|
474
481
|
refreshCommands,
|
package/data/usage/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
2
2
|
import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
|
|
3
3
|
import type { UsageWindows } from "@/lib/usage/windows";
|
|
4
4
|
import type { SpendByModel } from "@/lib/usage/by-model";
|
|
5
|
+
import type { Rollup, Span } from "@/lib/usage/span";
|
|
6
|
+
import type { Baseline, Supporting } from "@/lib/usage/ratchet";
|
|
7
|
+
import type { UsageConfig } from "@/lib/usage/config";
|
|
5
8
|
|
|
6
9
|
export const usageKeys = {
|
|
7
10
|
all: ["usage"] as const,
|
|
@@ -31,3 +34,28 @@ export function useUsageQuery(range: UsageRange, enabled = true) {
|
|
|
31
34
|
placeholderData: (prev) => prev,
|
|
32
35
|
});
|
|
33
36
|
}
|
|
37
|
+
|
|
38
|
+
export interface RollupsResponse {
|
|
39
|
+
rollups: Rollup[];
|
|
40
|
+
total: Span;
|
|
41
|
+
baseline: Baseline | null;
|
|
42
|
+
labels: Record<keyof Supporting, string>;
|
|
43
|
+
config: UsageConfig;
|
|
44
|
+
alerts: { kind: string; openedAt: number; message: string }[];
|
|
45
|
+
windows: UsageWindows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useUsageRollupsQuery(enabled = true) {
|
|
49
|
+
return useQuery({
|
|
50
|
+
queryKey: [...usageKeys.all, "rollups"] as const,
|
|
51
|
+
queryFn: async () => {
|
|
52
|
+
const res = await fetch("/api/usage/rollups");
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
55
|
+
return data as RollupsResponse;
|
|
56
|
+
},
|
|
57
|
+
enabled,
|
|
58
|
+
refetchInterval: enabled ? 5 * 60_000 : false,
|
|
59
|
+
placeholderData: (prev) => prev,
|
|
60
|
+
});
|
|
61
|
+
}
|
package/lib/chat/activity.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatItem, ChatState } from "./events";
|
|
2
|
+
import { formatTokens } from "./context";
|
|
2
3
|
import { formatElapsed } from "./elapsed";
|
|
3
4
|
import { registry, type Live } from "./registry";
|
|
4
5
|
import { stallThresholdMs } from "./stall";
|
|
@@ -94,7 +95,13 @@ export function stallOf(
|
|
|
94
95
|
thresholdMs: number
|
|
95
96
|
): Stall | null {
|
|
96
97
|
const a = live.activity;
|
|
97
|
-
|
|
98
|
+
// A compaction is silent for a while, by nature.
|
|
99
|
+
if (
|
|
100
|
+
live.state !== "running" ||
|
|
101
|
+
live.compacting ||
|
|
102
|
+
thresholdMs <= 0 ||
|
|
103
|
+
a.tasks.size > 0
|
|
104
|
+
)
|
|
98
105
|
return null;
|
|
99
106
|
// A foreground tool call (a long test run) says nothing while it works:
|
|
100
107
|
// it gets a much longer allowance before it counts as stuck.
|
|
@@ -110,6 +117,8 @@ export function chatStall(sessionId: string): Stall | null {
|
|
|
110
117
|
return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
export const COMPACTING_LABEL = "Compacting context";
|
|
121
|
+
|
|
113
122
|
// One line on what a chat session is doing, for the session list and
|
|
114
123
|
// notifications: the step running now and since when.
|
|
115
124
|
export function chatActivity(sessionId: string): {
|
|
@@ -117,6 +126,8 @@ export function chatActivity(sessionId: string): {
|
|
|
117
126
|
since?: number;
|
|
118
127
|
background: number;
|
|
119
128
|
stalled?: Stall;
|
|
129
|
+
// The context size going into a compaction under way.
|
|
130
|
+
compacting?: { before?: number };
|
|
120
131
|
} | null {
|
|
121
132
|
const live = registry.live.get(sessionId);
|
|
122
133
|
if (!live) return null;
|
|
@@ -126,6 +137,13 @@ export function chatActivity(sessionId: string): {
|
|
|
126
137
|
return { label: "Needs your answer", background };
|
|
127
138
|
if (live.state !== "running")
|
|
128
139
|
return background ? { label: "Idle", background } : null;
|
|
140
|
+
if (live.compacting)
|
|
141
|
+
return {
|
|
142
|
+
label: COMPACTING_LABEL,
|
|
143
|
+
since: live.compacting.since,
|
|
144
|
+
background,
|
|
145
|
+
compacting: { before: live.compacting.before },
|
|
146
|
+
};
|
|
129
147
|
const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
|
|
130
148
|
if (stalled)
|
|
131
149
|
return { label: "Stalled", since: stalled.since, background, stalled };
|
|
@@ -145,7 +163,11 @@ export function chatActivityLine(sessionId: string): string | null {
|
|
|
145
163
|
if (a.label === "Idle") return background || null;
|
|
146
164
|
if (a.stalled)
|
|
147
165
|
return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
|
|
148
|
-
const parts = [
|
|
166
|
+
const parts = [
|
|
167
|
+
a.compacting?.before
|
|
168
|
+
? `${a.label} (${formatTokens(a.compacting.before)} tokens)`
|
|
169
|
+
: a.label,
|
|
170
|
+
];
|
|
149
171
|
if (a.since) parts.push(formatElapsed(Date.now() - a.since));
|
|
150
172
|
if (background) parts.push(background);
|
|
151
173
|
return parts.join(" · ");
|
|
@@ -30,6 +30,7 @@ export type ClaudeMessage = {
|
|
|
30
30
|
usage?: {
|
|
31
31
|
input_tokens?: number;
|
|
32
32
|
output_tokens?: number;
|
|
33
|
+
cache_read_input_tokens?: number;
|
|
33
34
|
cache_creation_input_tokens?: number;
|
|
34
35
|
cache_creation?: {
|
|
35
36
|
ephemeral_5m_input_tokens?: number;
|
|
@@ -223,6 +224,13 @@ export class ClaudeMapper {
|
|
|
223
224
|
case "task_progress":
|
|
224
225
|
case "task_notification":
|
|
225
226
|
return this.task(m);
|
|
227
|
+
case "status": {
|
|
228
|
+
// "compacting" while the context is summarised, null once it's done.
|
|
229
|
+
const status = (m as { status?: string | null }).status;
|
|
230
|
+
return status === "compacting" || status === null
|
|
231
|
+
? [{ type: "compacting", active: status === "compacting" }]
|
|
232
|
+
: [];
|
|
233
|
+
}
|
|
226
234
|
case "compact_boundary":
|
|
227
235
|
return [
|
|
228
236
|
{
|
|
@@ -21,6 +21,7 @@ import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
|
21
21
|
import { SuggestionTrace } from "./suggestion-trace";
|
|
22
22
|
import { redact } from "../../orchestrator/untrusted";
|
|
23
23
|
import { NO_ATTRIBUTION } from "../../agents/attribution";
|
|
24
|
+
import { callFromAssistant } from "../../usage/calls";
|
|
24
25
|
|
|
25
26
|
// Claude Code through the Agent SDK, signed in with the user's own Claude
|
|
26
27
|
// Code login. Asks for approval through chat cards unless given full access,
|
|
@@ -245,6 +246,7 @@ export const claudeDriver: ChatDriver = {
|
|
|
245
246
|
)
|
|
246
247
|
.catch(() => {});
|
|
247
248
|
|
|
249
|
+
const agents = new Map<string, string>();
|
|
248
250
|
void (async () => {
|
|
249
251
|
try {
|
|
250
252
|
for await (const message of q) {
|
|
@@ -252,6 +254,7 @@ export const claudeDriver: ChatDriver = {
|
|
|
252
254
|
if (m.session_id) sessionId = m.session_id;
|
|
253
255
|
planFile = writtenPlanFile(m) ?? planFile;
|
|
254
256
|
trace.message(m);
|
|
257
|
+
for (const e of usageEvents(m, agents)) out.push(e);
|
|
255
258
|
for (const e of mapper.map(m)) out.push(e);
|
|
256
259
|
if (m.type === "assistant" && !m.parent_tool_use_id)
|
|
257
260
|
cacheTtl = cacheTtlMs(m.message?.usage?.cache_creation) ?? cacheTtl;
|
|
@@ -369,3 +372,46 @@ export const claudeDriver: ChatDriver = {
|
|
|
369
372
|
};
|
|
370
373
|
},
|
|
371
374
|
};
|
|
375
|
+
|
|
376
|
+
// The tokens of each response, subagents' too (the mapper drops those), and
|
|
377
|
+
// the CLI's limit readings, for the usage ledger. `agents` maps a subagent's
|
|
378
|
+
// parent tool call to its type, learned from the call that started it.
|
|
379
|
+
export function usageEvents(
|
|
380
|
+
m: ClaudeMessage,
|
|
381
|
+
agents: Map<string, string>,
|
|
382
|
+
at = Date.now()
|
|
383
|
+
): DriverEvent[] {
|
|
384
|
+
if (m.type === "rate_limit_event")
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
type: "rate_limit",
|
|
388
|
+
info: (m as { rate_limit_info?: unknown }).rate_limit_info,
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
if (m.type !== "assistant") return [];
|
|
392
|
+
const content = m.message?.content;
|
|
393
|
+
if (!m.parent_tool_use_id && Array.isArray(content))
|
|
394
|
+
for (const b of content as unknown as {
|
|
395
|
+
type?: string;
|
|
396
|
+
id?: string;
|
|
397
|
+
name?: string;
|
|
398
|
+
input?: { subagent_type?: unknown };
|
|
399
|
+
}[])
|
|
400
|
+
if (
|
|
401
|
+
b.type === "tool_use" &&
|
|
402
|
+
b.id &&
|
|
403
|
+
(b.name === "Agent" || b.name === "Task") &&
|
|
404
|
+
typeof b.input?.subagent_type === "string"
|
|
405
|
+
)
|
|
406
|
+
agents.set(b.id, b.input.subagent_type);
|
|
407
|
+
const agentType = m.parent_tool_use_id
|
|
408
|
+
? (agents.get(m.parent_tool_use_id) ?? null)
|
|
409
|
+
: null;
|
|
410
|
+
const kind = !m.parent_tool_use_id
|
|
411
|
+
? "main"
|
|
412
|
+
: agentType?.startsWith("review-")
|
|
413
|
+
? "review"
|
|
414
|
+
: "sub";
|
|
415
|
+
const call = callFromAssistant(m.message, kind, agentType, at);
|
|
416
|
+
return call ? [{ type: "call_usage", call }] : [];
|
|
417
|
+
}
|
|
@@ -69,6 +69,8 @@ export class CodexMapper {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
private started(id: string, item: P): void {
|
|
72
|
+
if (item.type === "contextCompaction")
|
|
73
|
+
return this.emit({ type: "compacting", active: true });
|
|
72
74
|
const start = toolStart(item);
|
|
73
75
|
if (start) this.items.tool(id, start);
|
|
74
76
|
}
|
|
@@ -82,11 +84,13 @@ export class CodexMapper {
|
|
|
82
84
|
);
|
|
83
85
|
if (item.type === "reasoning")
|
|
84
86
|
return this.items.finishText(id, "reasoning");
|
|
85
|
-
if (item.type === "contextCompaction")
|
|
87
|
+
if (item.type === "contextCompaction") {
|
|
88
|
+
this.emit({ type: "compacting", active: false });
|
|
86
89
|
return this.emit({
|
|
87
90
|
type: "item",
|
|
88
91
|
item: { id: randomUUID(), kind: "compacted", createdAt: Date.now() },
|
|
89
92
|
});
|
|
93
|
+
}
|
|
90
94
|
const start = toolStart(item);
|
|
91
95
|
if (!start) return;
|
|
92
96
|
if (!this.items.hasTool(id)) this.items.tool(id, start);
|
|
@@ -90,7 +90,11 @@ export class PiMapper {
|
|
|
90
90
|
e.isError ? "error" : "done",
|
|
91
91
|
resultText(e.result)
|
|
92
92
|
);
|
|
93
|
+
case "compaction_start":
|
|
94
|
+
return this.emit({ type: "compacting", active: true });
|
|
93
95
|
case "compaction_end":
|
|
96
|
+
// Failed or not, it's over.
|
|
97
|
+
this.emit({ type: "compacting", active: false });
|
|
94
98
|
if (!e.result) return;
|
|
95
99
|
return this.emit({
|
|
96
100
|
type: "item",
|
package/lib/chat/events.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CallUsage } from "../usage/calls";
|
|
1
2
|
import type { SearchResult } from "./find";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -266,6 +267,13 @@ export interface FileSuggestion {
|
|
|
266
267
|
// Waiting: a turn is paused on the reader (an approval or a question).
|
|
267
268
|
export type ChatState = "idle" | "running" | "waiting" | "error";
|
|
268
269
|
|
|
270
|
+
// A compaction in progress, alongside a "running" state: when it began and
|
|
271
|
+
// the context size going in, when known.
|
|
272
|
+
export interface Compacting {
|
|
273
|
+
since: number;
|
|
274
|
+
before?: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
269
277
|
export interface UndoPreview {
|
|
270
278
|
canUndo: boolean;
|
|
271
279
|
error?: string;
|
|
@@ -284,10 +292,14 @@ export type DriverEvent =
|
|
|
284
292
|
| { type: "suggestion"; text: string } // the agent's guess at the next message
|
|
285
293
|
| { type: "usage"; totals: UsageTotals } // running totals after a turn
|
|
286
294
|
| { type: "usage_start"; totals: UsageTotals } // the totals it started from
|
|
295
|
+
| { type: "call_usage"; call: CallUsage } // one response's tokens
|
|
296
|
+
| { type: "rate_limit"; info: unknown } // the CLI's limit reading
|
|
287
297
|
| { type: "context"; context: ChatContext } // context window use
|
|
288
298
|
| { type: "turn_start" } // the agent started a turn (maybe one nobody sent)
|
|
289
299
|
| { type: "at_rest" } // it has run every message it was given
|
|
290
|
-
| { type: "state"; state: ChatState }
|
|
300
|
+
| { type: "state"; state: ChatState }
|
|
301
|
+
// The agent started (true) or finished (false) compacting its context.
|
|
302
|
+
| { type: "compacting"; active: boolean };
|
|
291
303
|
|
|
292
304
|
// A tool call's output and diff, loaded when it's opened.
|
|
293
305
|
export interface ToolBody {
|
|
@@ -307,6 +319,7 @@ export type ChatServerMessage =
|
|
|
307
319
|
// Background tasks from before the page, for the composer's list.
|
|
308
320
|
tasks: ChatItem[];
|
|
309
321
|
state: ChatState;
|
|
322
|
+
compacting?: Compacting | null;
|
|
310
323
|
queue: QueuedMessage[];
|
|
311
324
|
suggestion: string | null;
|
|
312
325
|
}
|
|
@@ -331,6 +344,7 @@ export type ChatServerMessage =
|
|
|
331
344
|
| { type: "item"; item: ChatItem }
|
|
332
345
|
| { type: "delta"; id: string; text: string }
|
|
333
346
|
| { type: "state"; state: ChatState }
|
|
347
|
+
| { type: "compacting"; compacting: Compacting | null }
|
|
334
348
|
| {
|
|
335
349
|
type: "capabilities";
|
|
336
350
|
commands: ChatCommand[];
|
package/lib/chat/registry.ts
CHANGED
|
@@ -7,12 +7,15 @@ import type {
|
|
|
7
7
|
ChatModel,
|
|
8
8
|
ChatServerMessage,
|
|
9
9
|
ChatState,
|
|
10
|
+
Compacting,
|
|
10
11
|
} from "./events";
|
|
11
12
|
|
|
12
13
|
// A conversation whose worker this server is connected to.
|
|
13
14
|
export interface Live {
|
|
14
15
|
worker: WorkerClient;
|
|
15
16
|
state: ChatState;
|
|
17
|
+
// Set while its agent compacts the context, over a "running" state.
|
|
18
|
+
compacting?: Compacting | null;
|
|
16
19
|
// Streamed items carry their latest text here, ahead of SQLite.
|
|
17
20
|
streaming: Map<string, ChatItem>;
|
|
18
21
|
// What the turn is doing right now, for the session list.
|
|
@@ -82,7 +85,11 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
|
82
85
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
83
86
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
84
87
|
// sidebar hears at once.
|
|
85
|
-
if (
|
|
88
|
+
if (
|
|
89
|
+
m.type === "state" ||
|
|
90
|
+
m.type === "compacting" ||
|
|
91
|
+
(m.type === "item" && m.item.kind === "turn_end")
|
|
92
|
+
)
|
|
86
93
|
notifyStatusChanged();
|
|
87
94
|
}
|
|
88
95
|
|
package/lib/chat/runner.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ChatContext,
|
|
16
16
|
ChatImage,
|
|
17
17
|
ChatState,
|
|
18
|
+
Compacting,
|
|
18
19
|
SentBy,
|
|
19
20
|
UndoPreview,
|
|
20
21
|
} from "./events";
|
|
@@ -159,8 +160,14 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
159
160
|
const item = live.streaming.get(e.id);
|
|
160
161
|
if (item && "text" in item) item.text += e.text;
|
|
161
162
|
emit(sessionId, e);
|
|
163
|
+
} else if (e.type === "compacting") {
|
|
164
|
+
live.compacting = e.compacting;
|
|
165
|
+
emit(sessionId, e);
|
|
162
166
|
} else if (e.type === "state") {
|
|
163
167
|
live.state = e.state;
|
|
168
|
+
// Whatever ended the turn ended the compaction too.
|
|
169
|
+
if (e.state === "idle" || e.state === "error")
|
|
170
|
+
setCompacting(sessionId, live, null);
|
|
164
171
|
emit(sessionId, e);
|
|
165
172
|
clearTimeout(live.retiring);
|
|
166
173
|
live.retiring = undefined;
|
|
@@ -187,6 +194,16 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
187
194
|
}
|
|
188
195
|
}
|
|
189
196
|
|
|
197
|
+
function setCompacting(
|
|
198
|
+
sessionId: string,
|
|
199
|
+
live: Live,
|
|
200
|
+
compacting: Compacting | null
|
|
201
|
+
): void {
|
|
202
|
+
if (!live.compacting && !compacting) return;
|
|
203
|
+
live.compacting = compacting;
|
|
204
|
+
emit(sessionId, { type: "compacting", compacting });
|
|
205
|
+
}
|
|
206
|
+
|
|
190
207
|
function emitQueue(sessionId: string): void {
|
|
191
208
|
emit(sessionId, { type: "queue", queue: listQueue(sessionId) });
|
|
192
209
|
}
|
|
@@ -236,6 +253,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
236
253
|
onClose: (detached: boolean) => {
|
|
237
254
|
if (live && registry.live.get(sessionId) === live) {
|
|
238
255
|
registry.live.delete(sessionId);
|
|
256
|
+
emit(sessionId, { type: "compacting", compacting: null });
|
|
239
257
|
emit(sessionId, { type: "state", state: "idle" });
|
|
240
258
|
}
|
|
241
259
|
// It retired itself for a current worker, or ended its agent's
|
|
@@ -276,6 +294,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
276
294
|
live = {
|
|
277
295
|
worker: client,
|
|
278
296
|
state: hello.state,
|
|
297
|
+
compacting: hello.compacting ?? null,
|
|
279
298
|
streaming: new Map(hello.streaming.map((i) => [i.id, i])),
|
|
280
299
|
activity,
|
|
281
300
|
build: hello.build,
|
|
@@ -299,6 +318,8 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
299
318
|
if (live.canPlan)
|
|
300
319
|
client.command({ type: "set_plan", plan: !!saved.chat_plan });
|
|
301
320
|
emit(sessionId, { type: "state", state: live.state });
|
|
321
|
+
if (live.compacting)
|
|
322
|
+
emit(sessionId, { type: "compacting", compacting: live.compacting });
|
|
302
323
|
return live;
|
|
303
324
|
})();
|
|
304
325
|
registry.connecting.set(sessionId, connecting);
|
|
@@ -770,6 +791,8 @@ export function stopChat(sessionId: string): void {
|
|
|
770
791
|
const live = registry.live.get(sessionId);
|
|
771
792
|
if (live) {
|
|
772
793
|
registry.live.delete(sessionId);
|
|
794
|
+
if (live.compacting)
|
|
795
|
+
emit(sessionId, { type: "compacting", compacting: null });
|
|
773
796
|
live.worker.command({ type: "close" });
|
|
774
797
|
live.worker.detach();
|
|
775
798
|
return;
|
|
@@ -787,6 +810,9 @@ export async function releaseChat(sessionId: string): Promise<void> {
|
|
|
787
810
|
await resumeQueue(sessionId);
|
|
788
811
|
}
|
|
789
812
|
|
|
813
|
+
export const chatCompacting = (sessionId: string): Compacting | null =>
|
|
814
|
+
registry.live.get(sessionId)?.compacting ?? null;
|
|
815
|
+
|
|
790
816
|
export function chatState(sessionId: string): ChatState | null {
|
|
791
817
|
return registry.live.get(sessionId)?.state ?? null;
|
|
792
818
|
}
|
|
@@ -851,6 +877,7 @@ export function watchChat(
|
|
|
851
877
|
hasMore: page.hasMore,
|
|
852
878
|
tasks: present(sessionId, tasks),
|
|
853
879
|
state: live?.state ?? "idle",
|
|
880
|
+
compacting: live?.compacting ?? null,
|
|
854
881
|
queue: listQueue(sessionId),
|
|
855
882
|
suggestion: getSession(sessionId).chat_suggestion ?? null,
|
|
856
883
|
});
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
ChatImage,
|
|
18
18
|
ChatItem,
|
|
19
19
|
ChatState,
|
|
20
|
+
Compacting,
|
|
20
21
|
SentBy,
|
|
21
22
|
UsageTotals,
|
|
22
23
|
} from "../events";
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
refreshedNote,
|
|
49
50
|
releaseConversation,
|
|
50
51
|
} from "../refresh";
|
|
52
|
+
import { recordCall, sessionContext } from "../../usage/calls";
|
|
53
|
+
import { parseRateLimit, recordReadings } from "../../usage/rate-limits";
|
|
51
54
|
import { compactContext } from "../compact-context";
|
|
52
55
|
import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
|
|
53
56
|
import { compactionDue, staleLabel, type Staleness } from "../stale";
|
|
@@ -149,6 +152,9 @@ export class ChatHost {
|
|
|
149
152
|
private context: ChatContext | null = null;
|
|
150
153
|
// A compaction run ahead of a message (compactFirst): the message waits
|
|
151
154
|
// at the front of the queue until it ends.
|
|
155
|
+
// The agent compacting its context now, whoever started it: told to
|
|
156
|
+
// the server so it shows in place of plain "working".
|
|
157
|
+
compactingNow: Compacting | null = null;
|
|
152
158
|
private compacting: {
|
|
153
159
|
// Set when it ran because the chat was stale.
|
|
154
160
|
idleMs?: number;
|
|
@@ -275,11 +281,33 @@ export class ChatHost {
|
|
|
275
281
|
this.emit({ type: "item", item });
|
|
276
282
|
}
|
|
277
283
|
|
|
284
|
+
private setCompacting(on: boolean, before?: number): void {
|
|
285
|
+
if (on === !!this.compactingNow) return;
|
|
286
|
+
this.compactingNow = on
|
|
287
|
+
? {
|
|
288
|
+
since: Date.now(),
|
|
289
|
+
before: before ?? this.compacting?.before ?? this.context?.usedTokens,
|
|
290
|
+
}
|
|
291
|
+
: null;
|
|
292
|
+
this.emit({ type: "compacting", compacting: this.compactingNow });
|
|
293
|
+
}
|
|
294
|
+
|
|
278
295
|
private setState(state: ChatState): void {
|
|
279
296
|
this.state = state;
|
|
280
297
|
this.emit({ type: "state", state });
|
|
281
298
|
}
|
|
282
299
|
|
|
300
|
+
// The usage ledger is a measure: a failed write never touches a turn.
|
|
301
|
+
private ledger(write: () => void): void {
|
|
302
|
+
try {
|
|
303
|
+
write();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!this.ledgerFailed) console.error("[usage] ledger write:", error);
|
|
306
|
+
this.ledgerFailed = true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
private ledgerFailed = false;
|
|
310
|
+
|
|
283
311
|
private async pump(): Promise<void> {
|
|
284
312
|
try {
|
|
285
313
|
for await (const e of this.conversation.events) {
|
|
@@ -288,6 +316,8 @@ export class ChatHost {
|
|
|
288
316
|
// a failed one printed still shows.
|
|
289
317
|
if (this.compacting?.done && e.item.kind === "command_output")
|
|
290
318
|
continue;
|
|
319
|
+
if (e.item.kind === "compacted" || e.item.kind === "error")
|
|
320
|
+
this.setCompacting(false);
|
|
291
321
|
if (e.item.kind === "compacted") {
|
|
292
322
|
if (this.compacting) e.item = this.compactedItem(e.item);
|
|
293
323
|
else this.logCompacted(e.item, e.item.trigger ?? "manual");
|
|
@@ -329,6 +359,14 @@ export class ChatHost {
|
|
|
329
359
|
);
|
|
330
360
|
this.turnStartedAt = null;
|
|
331
361
|
this.turnsRecorded++;
|
|
362
|
+
} else if (e.type === "call_usage") {
|
|
363
|
+
this.ledger(() =>
|
|
364
|
+
recordCall(e.call, sessionContext(this.session.id), "live")
|
|
365
|
+
);
|
|
366
|
+
} else if (e.type === "rate_limit") {
|
|
367
|
+
this.ledger(() =>
|
|
368
|
+
recordReadings(parseRateLimit(e.info), "event", this.session.id)
|
|
369
|
+
);
|
|
332
370
|
} else if (e.type === "usage_start") {
|
|
333
371
|
// Too late once a turn was measured without it.
|
|
334
372
|
if (!this.turnsRecorded) this.usage = e.totals;
|
|
@@ -362,7 +400,12 @@ export class ChatHost {
|
|
|
362
400
|
this.currentTurn = null;
|
|
363
401
|
this.setState("running");
|
|
364
402
|
}
|
|
403
|
+
} else if (e.type === "compacting") {
|
|
404
|
+
this.setCompacting(e.active);
|
|
365
405
|
} else if (e.type === "state") {
|
|
406
|
+
// A stop, an error or the end of the turn ends a compaction too.
|
|
407
|
+
if (e.state === "idle" || e.state === "error")
|
|
408
|
+
this.setCompacting(false);
|
|
366
409
|
if (e.state === "idle") {
|
|
367
410
|
flushItems();
|
|
368
411
|
// A turn cut off mid-sentence (Esc, Stop) keeps what it streamed.
|
|
@@ -414,6 +457,7 @@ export class ChatHost {
|
|
|
414
457
|
createdAt: Date.now(),
|
|
415
458
|
});
|
|
416
459
|
} finally {
|
|
460
|
+
this.setCompacting(false);
|
|
417
461
|
this.settleStreaming();
|
|
418
462
|
this.closeTools();
|
|
419
463
|
this.showHandedOver();
|
|
@@ -481,6 +525,7 @@ export class ChatHost {
|
|
|
481
525
|
`UPDATE sessions SET chat_restarted = 0, chat_tools_digest = ? WHERE id = ?`
|
|
482
526
|
).run(this.toolsDigest, this.session.id);
|
|
483
527
|
}
|
|
528
|
+
if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
|
|
484
529
|
const checkpoint = now
|
|
485
530
|
? this.conversation.send(text, m.images, { now })
|
|
486
531
|
: this.conversation.send(text, m.images);
|
|
@@ -786,6 +831,7 @@ export class ChatHost {
|
|
|
786
831
|
this.currentTurn = null;
|
|
787
832
|
this.stopping = null;
|
|
788
833
|
this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
|
|
834
|
+
this.setCompacting(true, stale.tokens);
|
|
789
835
|
this.setState("running");
|
|
790
836
|
console.error(
|
|
791
837
|
`${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
|
|
@@ -910,6 +956,7 @@ export class ChatHost {
|
|
|
910
956
|
clearTimeout(this.retireTimer);
|
|
911
957
|
clearTimeout(this.restTimer);
|
|
912
958
|
this.closed = true;
|
|
959
|
+
this.setCompacting(false);
|
|
913
960
|
flushItems();
|
|
914
961
|
this.onClose();
|
|
915
962
|
this.conversation.close();
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -74,6 +74,7 @@ async function main(sessionId: string) {
|
|
|
74
74
|
version: PROTOCOL_VERSION,
|
|
75
75
|
build: buildId(),
|
|
76
76
|
state: host.state,
|
|
77
|
+
compacting: host.compactingNow,
|
|
77
78
|
streaming: [...host.streaming.values()],
|
|
78
79
|
caps: ["plan", "queue", "retire", "fresh"],
|
|
79
80
|
} satisfies WorkerEvent)}\n`
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ChatImage,
|
|
13
13
|
ChatItem,
|
|
14
14
|
ChatState,
|
|
15
|
+
Compacting,
|
|
15
16
|
DriverEvent,
|
|
16
17
|
FileSuggestion,
|
|
17
18
|
ChatOrigin,
|
|
@@ -72,6 +73,8 @@ export type WorkerEvent =
|
|
|
72
73
|
// The build of the server that started it (lib/build).
|
|
73
74
|
build?: string;
|
|
74
75
|
state: ChatState;
|
|
76
|
+
// A compaction under way (an older worker never says: not compacting).
|
|
77
|
+
compacting?: Compacting | null;
|
|
75
78
|
streaming: ChatItem[];
|
|
76
79
|
// Commands it understands beyond the first protocol ("plan"), so the
|
|
77
80
|
// server never trusts a worker from an older build with one it drops.
|
|
@@ -80,9 +83,19 @@ export type WorkerEvent =
|
|
|
80
83
|
| Exclude<
|
|
81
84
|
DriverEvent,
|
|
82
85
|
{
|
|
83
|
-
type:
|
|
86
|
+
type:
|
|
87
|
+
| "resume_id"
|
|
88
|
+
| "usage"
|
|
89
|
+
| "usage_start"
|
|
90
|
+
| "call_usage"
|
|
91
|
+
| "rate_limit"
|
|
92
|
+
| "suggestion"
|
|
93
|
+
| "at_rest"
|
|
94
|
+
| "compacting";
|
|
84
95
|
}
|
|
85
96
|
>
|
|
97
|
+
// Compacting began (with when, and the size going in) or ended (null).
|
|
98
|
+
| { type: "compacting"; compacting: Compacting | null }
|
|
86
99
|
// The agent's guess at the next message, or null once it's stale.
|
|
87
100
|
| { type: "suggestion"; text: string | null }
|
|
88
101
|
| { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
|