@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
@@ -0,0 +1,339 @@
1
+ /**
2
+ * What an orchestrator knows when it starts: the workspace's state read
3
+ * straight from the database, no model involved. It goes in the brief on
4
+ * every start, so a fresh conversation (a new orchestrator, or one started
5
+ * over at its context threshold) loses nothing that matters. Sections run
6
+ * most important first and the whole stays under a token budget; ordering
7
+ * is by id, so the same database gives the same text.
8
+ */
9
+
10
+ import { db } from "../db";
11
+ import { openAsks } from "./asks";
12
+ import { listRules } from "./rules";
13
+ import { shortId } from "./describe";
14
+ import { untrusted } from "./untrusted";
15
+
16
+ // About four characters a token: close enough to cap a prompt.
17
+ export const CHARS_PER_TOKEN = 4;
18
+ export const DIGEST_TOKENS = 4000;
19
+ const LINE_CAP = 300;
20
+ const NOTES_SCANNED = 200;
21
+ // An external PR gated this recently is still worth a line.
22
+ const EXTERNAL_DAYS = 2;
23
+
24
+ // One line, cut to length. A cut that leaves a fence around another
25
+ // session's text open closes it, so what follows isn't read as inside it.
26
+ const clip = (text: string, cap = LINE_CAP) => {
27
+ const one = text.replace(/\s+/g, " ").trim();
28
+ if (one.length <= cap) return one;
29
+ const cut = `${one.slice(0, cap - 3)}...`;
30
+ const opens = cut.match(/<untrusted[\s>]/g)?.length ?? 0;
31
+ const closes = cut.match(/<\/untrusted>/g)?.length ?? 0;
32
+ return opens > closes ? `${cut}</untrusted>` : cut;
33
+ };
34
+
35
+ const sha7 = (sha: string | null) => (sha ? sha.slice(0, 7) : "?");
36
+
37
+ interface Section {
38
+ title: string;
39
+ lines: string[];
40
+ // Shown when the section is empty; omitted otherwise.
41
+ empty?: string;
42
+ // How to see what didn't fit.
43
+ more: string;
44
+ }
45
+
46
+ interface TaskRow {
47
+ id: string;
48
+ name: string;
49
+ project: string | null;
50
+ pr_number: number | null;
51
+ pr_status: string | null;
52
+ view: string;
53
+ armed_at: string | null;
54
+ }
55
+
56
+ interface CheckRow {
57
+ session_id: string;
58
+ sha: string;
59
+ kind: string;
60
+ status: string;
61
+ }
62
+
63
+ interface FailureRow {
64
+ session_id: string;
65
+ gate: string;
66
+ count: number;
67
+ escalated_at: string | null;
68
+ }
69
+
70
+ function checksBySession(workspaceId: string): Map<string, CheckRow[]> {
71
+ // The newest verdict of each kind per session or PR.
72
+ const rows = db
73
+ .prepare(
74
+ `SELECT c.session_id, c.sha, c.kind, c.status FROM orchestrator_checks c
75
+ WHERE c.workspace_id = ? AND c.id = (
76
+ SELECT MAX(id) FROM orchestrator_checks
77
+ WHERE workspace_id = c.workspace_id AND session_id = c.session_id
78
+ AND kind = c.kind)
79
+ ORDER BY c.session_id, c.kind`
80
+ )
81
+ .all(workspaceId) as CheckRow[];
82
+ const by = new Map<string, CheckRow[]>();
83
+ for (const r of rows)
84
+ by.set(r.session_id, [...(by.get(r.session_id) ?? []), r]);
85
+ return by;
86
+ }
87
+
88
+ function failuresBySession(workspaceId: string): Map<string, FailureRow[]> {
89
+ const rows = db
90
+ .prepare(
91
+ `SELECT session_id, gate, count, escalated_at FROM orchestrator_gate_failures
92
+ WHERE workspace_id = ? AND count > 0 ORDER BY session_id, gate`
93
+ )
94
+ .all(workspaceId) as FailureRow[];
95
+ const by = new Map<string, FailureRow[]>();
96
+ for (const r of rows)
97
+ by.set(r.session_id, [...(by.get(r.session_id) ?? []), r]);
98
+ return by;
99
+ }
100
+
101
+ function gateState(
102
+ checks: CheckRow[] = [],
103
+ failures: FailureRow[] = []
104
+ ): string {
105
+ const parts: string[] = [];
106
+ if (checks.length)
107
+ parts.push(
108
+ checks.map((c) => `${c.kind} ${c.status}@${sha7(c.sha)}`).join(", ")
109
+ );
110
+ const failed = failures.map(
111
+ (f) =>
112
+ `${f.gate} failed ${f.count}x${f.escalated_at ? " (Saad's now)" : ""}`
113
+ );
114
+ if (failed.length) parts.push(failed.join(", "));
115
+ return parts.join("; ");
116
+ }
117
+
118
+ function rulesSection(workspaceId: string): Section {
119
+ return {
120
+ title: "Standing rules",
121
+ // Saad's in plain text; its own fenced, since it writes them from what
122
+ // it read: a line planted there can't pass for his word.
123
+ lines: listRules(workspaceId).map((r) =>
124
+ r.added_by === "saad"
125
+ ? `- [rule ${r.id}, from Saad] ${clip(r.text)}`
126
+ : `- [rule ${r.id}, kept by you] ${untrusted("a rule you kept", clip(r.text))}`
127
+ ),
128
+ empty: "- None. Keep one with rule_add.",
129
+ more: "",
130
+ };
131
+ }
132
+
133
+ function asksSection(workspaceId: string): Section {
134
+ return {
135
+ title: "Open asks (waiting on Saad)",
136
+ lines: openAsks(workspaceId).map((a) => {
137
+ const on = a.subject.startsWith("task:")
138
+ ? ` | task ${shortId(a.subject.slice(5))}`
139
+ : a.subject.startsWith("pr:")
140
+ ? ` | ${a.subject.slice(3)}`
141
+ : "";
142
+ const at = a.sha ? `@${sha7(a.sha)}` : "";
143
+ return `- [ask ${a.id}] ${a.kind}: ${untrusted("ask title", clip(a.title, 160))}${on}${at}`;
144
+ }),
145
+ empty: "- None.",
146
+ more: "",
147
+ };
148
+ }
149
+
150
+ function tasksSection(
151
+ workspaceId: string,
152
+ checks: Map<string, CheckRow[]>,
153
+ failures: Map<string, FailureRow[]>
154
+ ): Section {
155
+ const tasks = db
156
+ .prepare(
157
+ `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
158
+ s.merge_armed_at AS armed_at
159
+ FROM sessions s JOIN projects p ON p.id = s.project_id
160
+ WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
161
+ AND s.task_status = 'running'
162
+ ORDER BY s.created_at, s.id`
163
+ )
164
+ .all(workspaceId) as TaskRow[];
165
+ return {
166
+ title: "Running tasks",
167
+ lines: tasks.map((t) => {
168
+ const pr = t.pr_number
169
+ ? `PR #${t.pr_number} ${t.pr_status ?? "open"}`
170
+ : "no PR yet";
171
+ const gates = gateState(checks.get(t.id), failures.get(t.id));
172
+ const arm = t.armed_at ? " | armed to merge" : "";
173
+ return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${gates ? ` | ${gates}` : ""}`;
174
+ }),
175
+ empty: "- None.",
176
+ more: "the sessions tool",
177
+ };
178
+ }
179
+
180
+ function externalSection(
181
+ workspaceId: string,
182
+ checks: Map<string, CheckRow[]>,
183
+ failures: Map<string, FailureRow[]>
184
+ ): Section {
185
+ const recent = db
186
+ .prepare(
187
+ `SELECT session_id FROM orchestrator_checks
188
+ WHERE workspace_id = ? AND session_id LIKE 'pr:%'
189
+ AND created_at >= datetime('now', ?)
190
+ GROUP BY session_id ORDER BY session_id`
191
+ )
192
+ .all(workspaceId, `-${EXTERNAL_DAYS} days`) as { session_id: string }[];
193
+ return {
194
+ title: `External PRs gated in the last ${EXTERNAL_DAYS} days (may have merged since)`,
195
+ lines: recent.map(
196
+ ({ session_id }) =>
197
+ `- ${session_id.slice(3)} | ${gateState(checks.get(session_id), failures.get(session_id))}`
198
+ ),
199
+ more: "review on the PR",
200
+ };
201
+ }
202
+
203
+ function queueSection(workspaceId: string): Section {
204
+ const rows = db
205
+ .prepare(
206
+ `SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any,
207
+ p.name AS project, a.name AS after_name
208
+ FROM task_queue q JOIN projects p ON p.id = q.project_id
209
+ LEFT JOIN sessions a ON a.id = q.after_task
210
+ WHERE p.workspace_id = ? AND q.status IN ('queued', 'starting', 'failed')
211
+ ORDER BY q.position`
212
+ )
213
+ .all(workspaceId) as {
214
+ id: string;
215
+ name: string | null;
216
+ prompt: string;
217
+ status: string;
218
+ after_task: string | null;
219
+ after_any: string | null;
220
+ project: string;
221
+ after_name: string | null;
222
+ }[];
223
+ return {
224
+ title: "Queued tasks, in line order",
225
+ lines: rows.map((r, i) => {
226
+ const after = r.after_task
227
+ ? ` | after ${r.after_name ?? shortId(r.after_task)}`
228
+ : r.after_any
229
+ ? " | after any running task"
230
+ : "";
231
+ const status = r.status === "queued" ? "" : ` | ${r.status}`;
232
+ return `- ${i + 1}. ${clip(r.name ?? r.prompt, 80)} (${r.project}, id ${shortId(r.id)})${after}${status}`;
233
+ }),
234
+ more: "the sessions tool",
235
+ };
236
+ }
237
+
238
+ function schedulesSection(workspaceId: string): Section {
239
+ const rows = db
240
+ .prepare(
241
+ `SELECT name, cron, timezone, prompt, enabled FROM schedules
242
+ WHERE workspace_id = ? AND archived_at IS NULL ORDER BY created_at, id`
243
+ )
244
+ .all(workspaceId) as {
245
+ name: string;
246
+ cron: string;
247
+ timezone: string;
248
+ prompt: string;
249
+ enabled: number;
250
+ }[];
251
+ return {
252
+ title: "Schedules",
253
+ lines: rows.map(
254
+ (s) =>
255
+ `- "${clip(s.name, 60)}" ${s.cron} ${s.timezone}${s.enabled ? "" : " (off)"}: ${clip(s.prompt, 160)}`
256
+ ),
257
+ more: "`aos schedules`",
258
+ };
259
+ }
260
+
261
+ function notesSection(workspaceId: string): Section {
262
+ const rows = db
263
+ .prepare(
264
+ `SELECT kind, text, created_at FROM orchestrator_notes
265
+ WHERE workspace_id = ? ORDER BY id DESC LIMIT ?`
266
+ )
267
+ .all(workspaceId, NOTES_SCANNED) as {
268
+ kind: string;
269
+ text: string;
270
+ created_at: string;
271
+ }[];
272
+ return {
273
+ title: "Decision log, newest first",
274
+ lines: rows.map(
275
+ (n) =>
276
+ `- ${n.created_at.slice(0, 16)} UTC${n.kind === "note" ? "" : ` ${n.kind}`}: ${untrusted(n.kind === "note" ? "your note" : `${n.kind} log line`, clip(n.text))}`
277
+ ),
278
+ more: "",
279
+ };
280
+ }
281
+
282
+ // Sections in order until the budget runs out; a section cut short says
283
+ // how many lines it left out and where to find them.
284
+ export function renderDigest(
285
+ sections: Section[],
286
+ budgetTokens: number
287
+ ): string {
288
+ const budget = budgetTokens * CHARS_PER_TOKEN;
289
+ const out: string[] = [];
290
+ let used = 0;
291
+ const fits = (text: string) => used + text.length + 1 <= budget;
292
+ const put = (text: string) => {
293
+ out.push(text);
294
+ used += text.length + 1;
295
+ };
296
+ for (const s of sections) {
297
+ const lines = s.lines.length ? s.lines : s.empty ? [s.empty] : [];
298
+ if (!lines.length) continue;
299
+ const head = `\n### ${s.title}`;
300
+ if (!fits(`${head}\n${lines[0]}`)) break;
301
+ put(head);
302
+ let shown = 0;
303
+ for (const line of lines) {
304
+ if (!fits(line)) break;
305
+ put(line);
306
+ shown++;
307
+ }
308
+ const left = lines.length - shown;
309
+ if (left) {
310
+ const more = `- ...and ${left} more${s.more ? ` (see ${s.more})` : ""}.`;
311
+ if (fits(more)) put(more);
312
+ break;
313
+ }
314
+ }
315
+ return out.join("\n").trim();
316
+ }
317
+
318
+ export function buildDigest(
319
+ workspaceId: string,
320
+ budgetTokens = DIGEST_TOKENS
321
+ ): string {
322
+ const checks = checksBySession(workspaceId);
323
+ const failures = failuresBySession(workspaceId);
324
+ const head =
325
+ "## Where things stand (read from AgentOS's database as you started)";
326
+ const body = renderDigest(
327
+ [
328
+ rulesSection(workspaceId),
329
+ asksSection(workspaceId),
330
+ tasksSection(workspaceId, checks, failures),
331
+ externalSection(workspaceId, checks, failures),
332
+ queueSection(workspaceId),
333
+ schedulesSection(workspaceId),
334
+ notesSection(workspaceId),
335
+ ],
336
+ budgetTokens - Math.ceil((head.length + 2) / CHARS_PER_TOKEN)
337
+ );
338
+ return `${head}\n\n${body}`;
339
+ }