@saadnvd1/agent-os 0.3.457 → 0.3.460

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 (58) hide show
  1. package/README.md +42 -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/api/workspaces/[id]/route.ts +8 -1
  7. package/app/globals.css +10 -0
  8. package/components/Chat/ChatPanel.tsx +87 -4
  9. package/components/Chat/FindBar.tsx +113 -0
  10. package/components/Chat/Tools.tsx +1 -1
  11. package/components/Chat/find-highlight.test.ts +56 -0
  12. package/components/Chat/find-highlight.ts +78 -0
  13. package/components/Chat/rowState.tsx +44 -4
  14. package/components/Chat/stick-to-bottom.ts +7 -1
  15. package/components/Chat/useFind.ts +253 -0
  16. package/components/Pane/MobileTabBar.tsx +18 -0
  17. package/components/Pane/index.tsx +6 -0
  18. package/components/Workspaces/FreshStartForm.tsx +128 -0
  19. package/components/Workspaces/RulesForm.tsx +166 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +13 -1
  21. package/components/Workspaces/fresh-start.ts +19 -0
  22. package/data/chat/useChat.ts +29 -0
  23. package/data/fresh-start/index.ts +34 -0
  24. package/data/orchestrators/rules.ts +80 -0
  25. package/data/push/topics.ts +4 -0
  26. package/data/workspaces/index.ts +6 -1
  27. package/lib/chat/demo.ts +1 -0
  28. package/lib/chat/events.ts +7 -1
  29. package/lib/chat/find.ts +278 -0
  30. package/lib/chat/refresh-limits.ts +5 -0
  31. package/lib/chat/refresh.ts +82 -0
  32. package/lib/chat/runner.ts +11 -2
  33. package/lib/chat/search.ts +117 -0
  34. package/lib/chat/socket.ts +12 -0
  35. package/lib/chat/worker/extras.ts +11 -1
  36. package/lib/chat/worker/host.ts +70 -5
  37. package/lib/db/changes.ts +3 -0
  38. package/lib/db/migrations.ts +69 -0
  39. package/lib/db/types.ts +9 -0
  40. package/lib/orchestrator/brief.ts +16 -3
  41. package/lib/orchestrator/conditions.ts +10 -3
  42. package/lib/orchestrator/digest.ts +339 -0
  43. package/lib/orchestrator/pr-wake.ts +385 -0
  44. package/lib/orchestrator/presence-binding.ts +5 -0
  45. package/lib/orchestrator/rules.ts +124 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +10 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/orchestrator/watcher.ts +6 -2
  51. package/lib/orchestrator/worker.ts +5 -0
  52. package/lib/security/presence.ts +2 -1
  53. package/lib/tasks/gh.ts +2 -0
  54. package/lib/tasks/pr-activity.ts +295 -0
  55. package/lib/tasks/state.ts +9 -1
  56. package/lib/workspaces.ts +8 -2
  57. package/package.json +1 -1
  58. package/styles/themes.css +5 -0
@@ -1,11 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { useWorkspacesQuery } from "@/data/workspaces";
3
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
4
+ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
4
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
+ import { RulesForm } from "./RulesForm";
7
+ import { FreshStartForm } from "./FreshStartForm";
5
8
 
6
9
  // Settings > Workspaces: each workspace's own settings.
7
10
  export function WorkspaceSettings() {
8
11
  const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
12
+ const update = useUpdateWorkspace();
9
13
 
10
14
  if (isPending)
11
15
  return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
@@ -36,8 +40,16 @@ export function WorkspaceSettings() {
36
40
  key={`${w.id}:${w.max_running_tasks ?? "off"}`}
37
41
  workspace={w}
38
42
  />
43
+ <RulesForm workspaceId={w.id} />
44
+ <SettingToggle
45
+ title="Wake tasks on CI failures and reviews"
46
+ detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
47
+ checked={w.wake_tasks_on_pr}
48
+ onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
49
+ />
39
50
  </section>
40
51
  ))}
52
+ <FreshStartForm />
41
53
  </div>
42
54
  );
43
55
  }
@@ -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
 
@@ -53,6 +53,8 @@ export function useUpdateWorkspace() {
53
53
  collapsed?: boolean;
54
54
  // Running tasks at once; null for no limit.
55
55
  maxRunningTasks?: number | null;
56
+ // Message tasks about failing checks and reviews on their PRs.
57
+ wakeTasksOnPR?: boolean;
56
58
  }) =>
57
59
  json(
58
60
  await fetch(`/api/workspaces/${id}`, {
@@ -61,7 +63,7 @@ export function useUpdateWorkspace() {
61
63
  body: JSON.stringify(updates),
62
64
  })
63
65
  ),
64
- onMutate: async ({ id, maxRunningTasks, ...updates }) => {
66
+ onMutate: async ({ id, maxRunningTasks, wakeTasksOnPR, ...updates }) => {
65
67
  await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
66
68
  queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
67
69
  prev?.map((w) =>
@@ -72,6 +74,9 @@ export function useUpdateWorkspace() {
72
74
  ...(maxRunningTasks !== undefined && {
73
75
  max_running_tasks: maxRunningTasks,
74
76
  }),
77
+ ...(wakeTasksOnPR !== undefined && {
78
+ wake_tasks_on_pr: wakeTasksOnPR,
79
+ }),
75
80
  }
76
81
  : w
77
82
  )
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
+ }