@saadnvd1/agent-os 0.3.457 → 0.3.459

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.
Files changed (49) hide show
  1. package/README.md +30 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/globals.css +10 -0
  7. package/components/Chat/ChatPanel.tsx +87 -4
  8. package/components/Chat/FindBar.tsx +113 -0
  9. package/components/Chat/Tools.tsx +1 -1
  10. package/components/Chat/find-highlight.test.ts +56 -0
  11. package/components/Chat/find-highlight.ts +78 -0
  12. package/components/Chat/rowState.tsx +44 -4
  13. package/components/Chat/stick-to-bottom.ts +7 -1
  14. package/components/Chat/useFind.ts +253 -0
  15. package/components/Pane/MobileTabBar.tsx +18 -0
  16. package/components/Pane/index.tsx +6 -0
  17. package/components/Workspaces/FreshStartForm.tsx +128 -0
  18. package/components/Workspaces/RulesForm.tsx +166 -0
  19. package/components/Workspaces/WorkspaceSettings.tsx +4 -0
  20. package/components/Workspaces/fresh-start.ts +19 -0
  21. package/data/chat/useChat.ts +29 -0
  22. package/data/fresh-start/index.ts +34 -0
  23. package/data/orchestrators/rules.ts +80 -0
  24. package/data/push/topics.ts +4 -0
  25. package/lib/chat/demo.ts +1 -0
  26. package/lib/chat/events.ts +7 -1
  27. package/lib/chat/find.ts +278 -0
  28. package/lib/chat/refresh-limits.ts +5 -0
  29. package/lib/chat/refresh.ts +82 -0
  30. package/lib/chat/runner.ts +11 -2
  31. package/lib/chat/search.ts +117 -0
  32. package/lib/chat/socket.ts +12 -0
  33. package/lib/chat/worker/extras.ts +11 -1
  34. package/lib/chat/worker/host.ts +70 -5
  35. package/lib/db/changes.ts +3 -0
  36. package/lib/db/migrations.ts +37 -0
  37. package/lib/db/types.ts +4 -0
  38. package/lib/orchestrator/brief.ts +14 -3
  39. package/lib/orchestrator/digest.ts +339 -0
  40. package/lib/orchestrator/presence-binding.ts +5 -0
  41. package/lib/orchestrator/rules.ts +124 -0
  42. package/lib/orchestrator/serve.ts +11 -0
  43. package/lib/orchestrator/tool-names.ts +2 -0
  44. package/lib/orchestrator/tool-schemas.ts +10 -0
  45. package/lib/orchestrator/tools.ts +4 -0
  46. package/lib/orchestrator/worker.ts +5 -0
  47. package/lib/security/presence.ts +2 -1
  48. package/package.json +1 -1
  49. package/styles/themes.css +5 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useWorkspacesQuery } from "@/data/workspaces";
4
4
  import { TaskLimitForm } from "./TaskLimitForm";
5
+ import { RulesForm } from "./RulesForm";
6
+ import { FreshStartForm } from "./FreshStartForm";
5
7
 
6
8
  // Settings > Workspaces: each workspace's own settings.
7
9
  export function WorkspaceSettings() {
@@ -36,8 +38,10 @@ export function WorkspaceSettings() {
36
38
  key={`${w.id}:${w.max_running_tasks ?? "off"}`}
37
39
  workspace={w}
38
40
  />
41
+ <RulesForm workspaceId={w.id} />
39
42
  </section>
40
43
  ))}
44
+ <FreshStartForm />
41
45
  </div>
42
46
  );
43
47
  }
@@ -0,0 +1,19 @@
1
+ import {
2
+ MAX_REFRESH_TOKENS,
3
+ MIN_REFRESH_TOKENS,
4
+ } from "@/lib/chat/refresh-limits";
5
+
6
+ // A threshold's input, in thousands of tokens: off is 0; on takes a whole
7
+ // number in the range the server holds (lib/chat/refresh).
8
+ export function parseFreshStart(
9
+ on: boolean,
10
+ text: string
11
+ ): { tokens: number } | { error: string } {
12
+ if (!on) return { tokens: 0 };
13
+ const k = Number(text.trim());
14
+ const min = MIN_REFRESH_TOKENS / 1000;
15
+ const max = MAX_REFRESH_TOKENS / 1000;
16
+ if (!text.trim() || !Number.isInteger(k) || k < min || k > max)
17
+ return { error: `A whole number from ${min} to ${max}` };
18
+ return { tokens: k * 1000 };
19
+ }
@@ -18,6 +18,7 @@ import type {
18
18
  ToolBody,
19
19
  UndoPreview,
20
20
  } from "@/lib/chat/events";
21
+ import type { SearchResult } from "@/lib/chat/find";
21
22
 
22
23
  // The index of the item with `id`, searched from the end: what changes is
23
24
  // almost always the latest.
@@ -147,6 +148,9 @@ export function useChat(
147
148
  const fileRequests = useRef(
148
149
  new Map<string, (files: FileSuggestion[]) => void>()
149
150
  );
151
+ const searchRequests = useRef(
152
+ new Map<string, (result: SearchResult) => void>()
153
+ );
150
154
  const [taskOutputs, setTaskOutputs] = useState<Record<string, string | null>>(
151
155
  {}
152
156
  );
@@ -215,6 +219,9 @@ export function useChat(
215
219
  } else if (m.type === "files") {
216
220
  fileRequests.current.get(m.reqId)?.(m.files);
217
221
  fileRequests.current.delete(m.reqId);
222
+ } else if (m.type === "search") {
223
+ searchRequests.current.get(m.reqId)?.(m);
224
+ searchRequests.current.delete(m.reqId);
218
225
  } else if (m.type === "state") {
219
226
  setState(m.state);
220
227
  } else if (m.type === "capabilities") {
@@ -393,6 +400,27 @@ export function useChat(
393
400
  });
394
401
  }, []);
395
402
 
403
+ // A page of find's results from the stored conversation, newest first;
404
+ // null when the socket is down or no answer comes (a linked machine's
405
+ // AgentOS too old to search).
406
+ const search = useCallback((query: string, before?: number) => {
407
+ const ws = wsRef.current;
408
+ if (!ws || ws.readyState !== WebSocket.OPEN)
409
+ return Promise.resolve<SearchResult | null>(null);
410
+ const reqId = Math.random().toString(36).slice(2);
411
+ return new Promise<SearchResult | null>((resolve) => {
412
+ const timer = setTimeout(() => {
413
+ searchRequests.current.delete(reqId);
414
+ resolve(null);
415
+ }, 10_000);
416
+ searchRequests.current.set(reqId, (result) => {
417
+ clearTimeout(timer);
418
+ resolve(result);
419
+ });
420
+ ws.send(JSON.stringify({ type: "search", reqId, query, before }));
421
+ });
422
+ }, []);
423
+
396
424
  const loadTaskOutput = useCallback((taskId: string) => {
397
425
  wsRef.current?.send(JSON.stringify({ type: "task_output", taskId }));
398
426
  }, []);
@@ -451,5 +479,6 @@ export function useChat(
451
479
  queueSendNow,
452
480
  suggestion,
453
481
  requestFiles,
482
+ search,
454
483
  };
455
484
  }
@@ -0,0 +1,34 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { RefreshSettings } from "@/lib/chat/refresh";
3
+
4
+ // When chats start a fresh conversation (lib/chat/refresh).
5
+ export const freshStartKeys = { all: ["fresh-start"] as const };
6
+
7
+ async function json<T>(res: Response): Promise<T> {
8
+ const data = await res.json().catch(() => ({}));
9
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
10
+ return data;
11
+ }
12
+
13
+ export function useFreshStartQuery() {
14
+ return useQuery({
15
+ queryKey: freshStartKeys.all,
16
+ queryFn: async () => json<RefreshSettings>(await fetch("/api/fresh-start")),
17
+ staleTime: 60000,
18
+ });
19
+ }
20
+
21
+ export function useUpdateFreshStart() {
22
+ const queryClient = useQueryClient();
23
+ return useMutation({
24
+ mutationFn: async (settings: Partial<RefreshSettings>) =>
25
+ json<RefreshSettings>(
26
+ await fetch("/api/fresh-start", {
27
+ method: "PUT",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(settings),
30
+ })
31
+ ),
32
+ onSuccess: (data) => queryClient.setQueryData(freshStartKeys.all, data),
33
+ });
34
+ }
@@ -0,0 +1,80 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { RuleRow } from "@/lib/orchestrator/rules";
3
+ import { provePresence } from "../presence";
4
+
5
+ // A workspace orchestrator's standing rules (lib/orchestrator/rules).
6
+ // Pushed when the table changes (data/push/topics).
7
+ export const ruleKeys = {
8
+ all: ["orchestrator-rules"] as const,
9
+ list: (workspaceId: string) => [...ruleKeys.all, workspaceId] as const,
10
+ };
11
+
12
+ const url = (workspaceId: string, ruleId?: number) =>
13
+ `/api/workspaces/${workspaceId}/orchestrator/rules${ruleId ? `/${ruleId}` : ""}`;
14
+
15
+ async function call<T>(input: string, init?: RequestInit): Promise<T> {
16
+ const res = await fetch(input, {
17
+ ...init,
18
+ headers: { "Content-Type": "application/json" },
19
+ }).catch(() => {
20
+ throw new Error("Couldn't reach AgentOS, so this may not be saved.");
21
+ });
22
+ const data = await res.json().catch(() => ({}));
23
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
24
+ return data;
25
+ }
26
+
27
+ export function useRulesQuery(workspaceId: string) {
28
+ return useQuery({
29
+ queryKey: ruleKeys.list(workspaceId),
30
+ queryFn: async () =>
31
+ (await call<{ rules: RuleRow[] }>(url(workspaceId))).rules,
32
+ staleTime: 30000,
33
+ });
34
+ }
35
+
36
+ function useRuleMutation<V>(
37
+ workspaceId: string,
38
+ request: (v: V) => Promise<unknown>
39
+ ) {
40
+ const queryClient = useQueryClient();
41
+ return useMutation({
42
+ mutationFn: request,
43
+ onSettled: () =>
44
+ queryClient.invalidateQueries({ queryKey: ruleKeys.list(workspaceId) }),
45
+ });
46
+ }
47
+
48
+ // A rule written here is Saad's word to the orchestrator: each change
49
+ // takes his passkey.
50
+ const proven = async (workspaceId: string, body: object = {}) =>
51
+ JSON.stringify({
52
+ ...body,
53
+ assertion: await provePresence({ purpose: "rules", workspaceId }),
54
+ });
55
+
56
+ export const useAddRule = (workspaceId: string) =>
57
+ useRuleMutation(workspaceId, async (text: string) =>
58
+ call(url(workspaceId), {
59
+ method: "POST",
60
+ body: await proven(workspaceId, { text }),
61
+ })
62
+ );
63
+
64
+ export const useUpdateRule = (workspaceId: string) =>
65
+ useRuleMutation(
66
+ workspaceId,
67
+ async ({ id, text }: { id: number; text: string }) =>
68
+ call(url(workspaceId, id), {
69
+ method: "PATCH",
70
+ body: await proven(workspaceId, { text }),
71
+ })
72
+ );
73
+
74
+ export const useRemoveRule = (workspaceId: string) =>
75
+ useRuleMutation(workspaceId, async (id: number) =>
76
+ call(url(workspaceId, id), {
77
+ method: "DELETE",
78
+ body: await proven(workspaceId),
79
+ })
80
+ );
@@ -8,6 +8,7 @@ import { devServerKeys } from "../dev-servers/keys";
8
8
  import { busKeys } from "../bus";
9
9
  import { stackKeys } from "../stacks";
10
10
  import { orchestratorKeys } from "../orchestrators";
11
+ import { ruleKeys } from "../orchestrators/rules";
11
12
  import { workspaceKeys } from "../workspaces";
12
13
  import { scheduleKeys } from "../schedules";
13
14
  import { archivedKeys } from "../done";
@@ -53,6 +54,8 @@ function keysFor(topic: string): QueryKey[] {
53
54
  return [scheduleKeys.all];
54
55
  case "orchestrator_asks":
55
56
  return [orchestratorKeys.all];
57
+ case "orchestrator_rules":
58
+ return [ruleKeys.all];
56
59
  case "dev_servers":
57
60
  return [devServerKeys.all];
58
61
  default:
@@ -92,6 +95,7 @@ export const ALL_TOPICS = [
92
95
  "bus_messages",
93
96
  "schedules",
94
97
  "orchestrator_asks",
98
+ "orchestrator_rules",
95
99
  "dev_servers",
96
100
  ];
97
101
 
package/lib/chat/demo.ts CHANGED
@@ -72,6 +72,7 @@ export const DEMO_CHAT_READS = new Set<ChatClientMessage["type"]>([
72
72
  "history",
73
73
  "tool_body",
74
74
  "task_output",
75
+ "search",
75
76
  ]);
76
77
 
77
78
  /**
@@ -1,3 +1,5 @@
1
+ import type { SearchResult } from "./find";
2
+
1
3
  /**
2
4
  * The provider-neutral shape of a chat conversation. Every driver (Claude
3
5
  * today, others later) turns its own protocol into these items; storage,
@@ -300,6 +302,8 @@ export type ChatServerMessage =
300
302
  hasMore: boolean;
301
303
  }
302
304
  | { type: "tool_body"; id: string; body: ToolBody | null }
305
+ // Find in the conversation: one page of results for the request `reqId`.
306
+ | ({ type: "search"; reqId: string; query: string } & SearchResult)
303
307
  | { type: "queue"; queue: QueuedMessage[] }
304
308
  | { type: "suggestion"; text: string | null }
305
309
  | {
@@ -348,4 +352,6 @@ export type ChatClientMessage =
348
352
  | { type: "task_output"; taskId: string }
349
353
  // Older items, from before a page's cursor.
350
354
  | { type: "history"; before: number }
351
- | { type: "tool_body"; id: string };
355
+ | { type: "tool_body"; id: string }
356
+ // Older results from before `before`, a cursor from an earlier page.
357
+ | { type: "search"; reqId: string; query: string; before?: number };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Find in a conversation: what text each item lets a reader find (what its
3
+ * row shows once opened), where a match sits in it, and which rows to open to
4
+ * show it. Shared by the server's search and the panel's find bar, so both
5
+ * count the same matches.
6
+ */
7
+
8
+ import type { ChatItem } from "./events";
9
+ import { lineDiff } from "./diff";
10
+ import { groupTimeline, STANDALONE_TOOLS, type ToolItem } from "./group";
11
+ import { eventParts, originOf } from "./origin";
12
+
13
+ export const MIN_QUERY = 2;
14
+ export const MAX_QUERY = 200;
15
+
16
+ // Whether `query` could be in a diff at all, before paying for lineDiff.
17
+ const inDiff = (diff: NonNullable<ToolItem["diff"]>, query: string) => {
18
+ const q = query.toLowerCase();
19
+ return (
20
+ diff.before.toLowerCase().includes(q) ||
21
+ diff.after.toLowerCase().includes(q)
22
+ );
23
+ };
24
+
25
+ function toolText(tool: ToolItem, query?: string): string {
26
+ if (tool.name === "Skill") return tool.title;
27
+ const input = (tool.input ?? {}) as Record<string, unknown>;
28
+ if (STANDALONE_TOOLS.has(tool.name))
29
+ return [tool.title, input.prompt, tool.output].filter(Boolean).join("\n");
30
+ const parts = [tool.title];
31
+ if (tool.diff && query !== undefined && !inDiff(tool.diff, query))
32
+ parts.push(tool.diff.path);
33
+ else if (tool.diff)
34
+ parts.push(
35
+ tool.diff.path,
36
+ ...lineDiff(tool.diff.before, tool.diff.after).map((l) => l.text)
37
+ );
38
+ else {
39
+ parts.push(
40
+ tool.name === "Bash" && typeof input.command === "string"
41
+ ? input.command
42
+ : JSON.stringify(tool.input, null, 2)
43
+ );
44
+ if (tool.output) parts.push(tool.output);
45
+ }
46
+ return parts.join("\n");
47
+ }
48
+
49
+ // What a reader can find in an item: its row's text, opened. Nothing for
50
+ // items the timeline doesn't show. With `query`, a diff it can't be in is
51
+ // left out rather than laid out line by line.
52
+ export function findableText(item: ChatItem, query?: string): string {
53
+ switch (item.kind) {
54
+ case "user": {
55
+ const origin = originOf(item);
56
+ if (!origin) return item.text;
57
+ const { body, decided } = eventParts(item, origin);
58
+ return [...decided, body].join("\n");
59
+ }
60
+ case "assistant":
61
+ case "reasoning":
62
+ case "command_output":
63
+ case "note":
64
+ return item.text;
65
+ case "plan":
66
+ return item.plan;
67
+ case "error":
68
+ return item.message;
69
+ case "todos":
70
+ return item.todos.map((t) => t.text).join("\n");
71
+ case "artifact":
72
+ return item.title;
73
+ case "approval":
74
+ // A settled one is told by its tool call (lib/chat/group).
75
+ if (
76
+ item.status !== "pending" &&
77
+ !(item.status === "answered" && item.questions)
78
+ )
79
+ return "";
80
+ return [item.title, ...(item.questions ?? []).map((q) => q.question)]
81
+ .filter(Boolean)
82
+ .join("\n");
83
+ case "tool":
84
+ return toolText(item, query);
85
+ default:
86
+ return "";
87
+ }
88
+ }
89
+
90
+ // Where `query` occurs in `text`, case-insensitively and not overlapping.
91
+ export function occurrences(text: string, query: string): number[] {
92
+ const q = query.toLowerCase();
93
+ if (!q) return [];
94
+ const t = text.toLowerCase();
95
+ const at: number[] = [];
96
+ for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + q.length))
97
+ at.push(i);
98
+ return at;
99
+ }
100
+
101
+ export interface Snippet {
102
+ before: string;
103
+ match: string;
104
+ after: string;
105
+ }
106
+
107
+ // A line's worth around a match, for a result list or a log.
108
+ export function snippetAt(
109
+ text: string,
110
+ at: number,
111
+ length: number,
112
+ around = 40
113
+ ): Snippet {
114
+ const flat = (s: string) => s.replace(/\s+/g, " ");
115
+ const start = Math.max(0, at - around);
116
+ const end = Math.min(text.length, at + length + around);
117
+ return {
118
+ before: (start > 0 ? "…" : "") + flat(text.slice(start, at)).trimStart(),
119
+ match: flat(text.slice(at, at + length)),
120
+ after:
121
+ flat(text.slice(at + length, end)).trimEnd() +
122
+ (end < text.length ? "…" : ""),
123
+ };
124
+ }
125
+
126
+ export interface SearchHit {
127
+ id: string;
128
+ seq: number;
129
+ // Matches in this item, in the order its row shows them.
130
+ count: number;
131
+ snippet: Snippet;
132
+ }
133
+
134
+ export interface SearchResult {
135
+ // Matches in the whole conversation, up to MAX_MATCHES.
136
+ total: number;
137
+ capped: boolean;
138
+ // Items with a match, newest first, older than the asked-for cursor.
139
+ hits: SearchHit[];
140
+ // The oldest hit's position: the next page asks from before it.
141
+ cursor: number | null;
142
+ hasMore: boolean;
143
+ }
144
+
145
+ export interface Located {
146
+ // The timeline block to scroll to.
147
+ block: string;
148
+ // Row states to open, outermost first (components/Chat/rowState).
149
+ open: string[];
150
+ }
151
+
152
+ function rowKeys(item: ChatItem): string[] {
153
+ if (item.kind === "reasoning") return [`reasoning:${item.id}`];
154
+ if (item.kind === "tool" && STANDALONE_TOOLS.has(item.name))
155
+ return item.name === "Skill" ? [] : [`agent:${item.id}`];
156
+ if (item.kind === "user") {
157
+ const origin = originOf(item);
158
+ if (!origin) return [];
159
+ const { decided } = eventParts(item, origin);
160
+ return [
161
+ `event:${item.id}`,
162
+ ...decided.map((_, i) => `event:${item.id}:${i}`),
163
+ ];
164
+ }
165
+ return [];
166
+ }
167
+
168
+ /**
169
+ * Which block of the loaded timeline shows an item, and which rows to open
170
+ * so it's on screen: a step inside its folded run, a message inside an undo.
171
+ * Null when it isn't loaded (or the timeline never shows it).
172
+ */
173
+ export function locate(items: ChatItem[], id: string): Located | null {
174
+ const at = items.findIndex((i) => i.id === id);
175
+ if (at === -1) return null;
176
+ const inside = (
177
+ blocks: ReturnType<typeof groupTimeline>,
178
+ open: string[]
179
+ ): Omit<Located, "block"> | null => {
180
+ for (const b of blocks) {
181
+ if (b.type === "item" && b.item.id === id)
182
+ return { open: [...open, ...rowKeys(b.item)] };
183
+ if (b.type === "tools" && b.tools.some((t) => t.id === id))
184
+ return { open: [...open, `steps:${b.id}`, `tool:${id}`] };
185
+ if (b.type === "undone" && b.items.some((i) => i.id === id))
186
+ return (
187
+ inside(groupTimeline(b.items), [...open, `undone:${b.undo.id}`]) ?? {
188
+ open: [...open, `undone:${b.undo.id}`],
189
+ }
190
+ );
191
+ }
192
+ return null;
193
+ };
194
+ const blocks = groupTimeline(items);
195
+ const blockOf = (b: (typeof blocks)[number]) =>
196
+ b.type === "item" ? b.item.id : b.id;
197
+ for (const b of blocks) {
198
+ const found = inside([b], []);
199
+ if (found) return { block: blockOf(b), ...found };
200
+ }
201
+ // Reasoning between steps folds out of sight: the run it sits in.
202
+ for (let i = at - 1; i >= 0; i--) {
203
+ const before = items[i];
204
+ if (before.kind !== "tool") continue;
205
+ const run = blocks.find(
206
+ (b) => b.type === "tools" && b.tools.some((t) => t.id === before.id)
207
+ );
208
+ if (run) return { block: blockOf(run), open: [`steps:${blockOf(run)}`] };
209
+ break;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ // One match to go to: the item, and which of its matches (in its own order).
215
+ export interface FindTarget {
216
+ id: string;
217
+ n: number;
218
+ }
219
+
220
+ // Results newest first, as the reader meets them scrolling up from the end:
221
+ // an item's last match before its first.
222
+ export function flattenHits(hits: SearchHit[]): FindTarget[] {
223
+ return hits.flatMap((h) =>
224
+ Array.from({ length: h.count }, (_, i) => ({
225
+ id: h.id,
226
+ n: h.count - 1 - i,
227
+ }))
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Where next (+1, older) or previous (-1, newer) goes from `index` among
233
+ * `loaded` results: "more" when the next is on a page not fetched yet. It
234
+ * wraps around only once every result is loaded.
235
+ */
236
+ export function stepIndex(
237
+ index: number,
238
+ dir: 1 | -1,
239
+ loaded: number,
240
+ hasMore: boolean
241
+ ): number | "more" {
242
+ if (!loaded) return 0;
243
+ const next = index + dir;
244
+ if (next >= loaded) return hasMore ? "more" : 0;
245
+ if (next < 0) return hasMore ? 0 : loaded - 1;
246
+ return next;
247
+ }
248
+
249
+ // Results walked so far: the first page's count, then every page's hits.
250
+ export interface FindResults extends SearchResult {
251
+ query: string;
252
+ }
253
+
254
+ // Pages on from `before` until one has a hit (a page that read only a slice
255
+ // of a huge conversation may have none) or there are no more.
256
+ export async function nextHits(
257
+ search: (query: string, before?: number) => Promise<SearchResult | null>,
258
+ query: string,
259
+ before: number | null,
260
+ maxPages = 100
261
+ ): Promise<SearchResult | null> {
262
+ let page = await search(query, before ?? undefined);
263
+ for (
264
+ let i = 1;
265
+ page && !page.hits.length && page.hasMore && i < maxPages;
266
+ i++
267
+ )
268
+ page = await search(query, page.cursor ?? undefined);
269
+ return page;
270
+ }
271
+
272
+ // Older hits joined on; the count and its cap stay the first page's.
273
+ export const joinPage = (r: FindResults, page: SearchResult): FindResults => ({
274
+ ...r,
275
+ hits: r.hits.concat(page.hits),
276
+ hasMore: page.hasMore,
277
+ cursor: page.cursor,
278
+ });
@@ -0,0 +1,5 @@
1
+ // The range a fresh-start threshold may take (lib/chat/refresh), shared
2
+ // with the settings form. Below the minimum a fresh brief and digest alone
3
+ // could be most of the budget.
4
+ export const MIN_REFRESH_TOKENS = 50_000;
5
+ export const MAX_REFRESH_TOKENS = 900_000;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Fresh starts: a chat whose context passes a threshold ends its agent's
3
+ * conversation at the next turn boundary, and the next worker starts a new
4
+ * one with the brief (lib/chat/worker/host). An orchestrator is fed events
5
+ * nonstop, never idles and can't compact itself, so it's on for it by
6
+ * default: its state is in the database and its brief carries it
7
+ * (lib/orchestrator/digest). A task keeps its progress only in its
8
+ * conversation and its branch, so for tasks it's off unless switched on.
9
+ * App-wide, in Settings → Workspaces.
10
+ */
11
+
12
+ import { db } from "../db";
13
+ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
14
+
15
+ export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
16
+ export const TASK_KEY = "chat.task_refresh_tokens";
17
+ export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
18
+ export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
19
+
20
+ export interface RefreshSettings {
21
+ // Context tokens at which a conversation starts over; 0 is off.
22
+ orchestratorTokens: number;
23
+ taskTokens: number;
24
+ }
25
+
26
+ function read(key: string, fallback: number): number {
27
+ const row = db
28
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
29
+ .get(key) as { value: string } | undefined;
30
+ const n = row ? Number(row.value) : fallback;
31
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
32
+ }
33
+
34
+ export function refreshSettings(): RefreshSettings {
35
+ return {
36
+ orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
37
+ taskTokens: read(TASK_KEY, 0),
38
+ };
39
+ }
40
+
41
+ // A threshold, checked: 0 (off) or a whole number in range. Throws a plain
42
+ // message otherwise.
43
+ export function checkRefreshTokens(value: unknown): number {
44
+ if (value === 0) return 0;
45
+ if (
46
+ typeof value !== "number" ||
47
+ !Number.isInteger(value) ||
48
+ value < MIN_REFRESH_TOKENS ||
49
+ value > MAX_REFRESH_TOKENS
50
+ )
51
+ throw new Error(
52
+ `A fresh-start threshold is off or between ${MIN_REFRESH_TOKENS / 1000}k and ${MAX_REFRESH_TOKENS / 1000}k tokens`
53
+ );
54
+ return value;
55
+ }
56
+
57
+ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
58
+ const put = db.prepare(
59
+ `INSERT INTO settings (key, value) VALUES (?, ?)
60
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
61
+ );
62
+ const checked: [string, number][] = [];
63
+ if (input.orchestratorTokens !== undefined)
64
+ checked.push([
65
+ ORCHESTRATOR_KEY,
66
+ checkRefreshTokens(input.orchestratorTokens),
67
+ ]);
68
+ if (input.taskTokens !== undefined)
69
+ checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
70
+ db.transaction(() => {
71
+ for (const [key, n] of checked) put.run(key, String(n));
72
+ })();
73
+ }
74
+
75
+ const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
76
+
77
+ // What the agent reads ahead of its first message in the new conversation.
78
+ export function refreshedNote(role: "orchestrator" | "task", tokens: number) {
79
+ return role === "orchestrator"
80
+ ? `(AgentOS started you on a fresh conversation: the last one passed ${k(tokens)} tokens of context. Your brief and "Where things stand" are current; carry on from them.)`
81
+ : `(AgentOS started you on a fresh conversation: the last one passed ${k(tokens)} tokens of context. Your brief is above; check git status, git log and your PR to see how far you got, then carry on.)`;
82
+ }
@@ -226,8 +226,10 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
226
226
  registry.live.delete(sessionId);
227
227
  emit(sessionId, { type: "state", state: "idle" });
228
228
  }
229
- // It retired itself for a current worker: what it queued goes now.
230
- if (live?.retireSent) resumed.delete(sessionId);
229
+ // It retired itself for a current worker, or ended its agent's
230
+ // conversation at the context threshold: what it queued goes now.
231
+ if (live?.retireSent || refreshedAway(sessionId))
232
+ resumed.delete(sessionId);
231
233
  // Its agent died mid-turn, with messages still queued, or with a
232
234
  // turn cut off. (One this server let go, it stopped on purpose.)
233
235
  if (live && !detached)
@@ -375,6 +377,13 @@ export function deleteQueuedChat(sessionId: string, id: string) {
375
377
  emitQueue(sessionId);
376
378
  }
377
379
 
380
+ const refreshedAway = (sessionId: string) =>
381
+ !!(
382
+ db
383
+ .prepare(`SELECT chat_refreshed FROM sessions WHERE id = ?`)
384
+ .get(sessionId) as { chat_refreshed: number } | undefined
385
+ )?.chat_refreshed;
386
+
378
387
  // A queue no worker is left to send (it was retired, or its agent died):
379
388
  // a fresh worker sends it in order. Never interrupts: a worker that's busy
380
389
  // sends its queue when its turn ends. At most once a minute per