@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.
- package/README.md +30 -5
- package/app/api/fresh-start/route.ts +19 -0
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
- package/app/globals.css +10 -0
- package/components/Chat/ChatPanel.tsx +87 -4
- package/components/Chat/FindBar.tsx +113 -0
- package/components/Chat/Tools.tsx +1 -1
- package/components/Chat/find-highlight.test.ts +56 -0
- package/components/Chat/find-highlight.ts +78 -0
- package/components/Chat/rowState.tsx +44 -4
- package/components/Chat/stick-to-bottom.ts +7 -1
- package/components/Chat/useFind.ts +253 -0
- package/components/Pane/MobileTabBar.tsx +18 -0
- package/components/Pane/index.tsx +6 -0
- package/components/Workspaces/FreshStartForm.tsx +128 -0
- package/components/Workspaces/RulesForm.tsx +166 -0
- package/components/Workspaces/WorkspaceSettings.tsx +4 -0
- package/components/Workspaces/fresh-start.ts +19 -0
- package/data/chat/useChat.ts +29 -0
- package/data/fresh-start/index.ts +34 -0
- package/data/orchestrators/rules.ts +80 -0
- package/data/push/topics.ts +4 -0
- package/lib/chat/demo.ts +1 -0
- package/lib/chat/events.ts +7 -1
- package/lib/chat/find.ts +278 -0
- package/lib/chat/refresh-limits.ts +5 -0
- package/lib/chat/refresh.ts +82 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/search.ts +117 -0
- package/lib/chat/socket.ts +12 -0
- package/lib/chat/worker/extras.ts +11 -1
- package/lib/chat/worker/host.ts +70 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +37 -0
- package/lib/db/types.ts +4 -0
- package/lib/orchestrator/brief.ts +14 -3
- package/lib/orchestrator/digest.ts +339 -0
- package/lib/orchestrator/presence-binding.ts +5 -0
- package/lib/orchestrator/rules.ts +124 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +10 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/worker.ts +5 -0
- package/lib/security/presence.ts +2 -1
- package/package.json +1 -1
- 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
|
+
}
|
|
@@ -10,6 +10,8 @@ export const askPresence = (askId: number, binding: string) =>
|
|
|
10
10
|
export const resumePresence = (workspaceId: string) => `resume:${workspaceId}`;
|
|
11
11
|
export const revokePresence = (passkeyId: string) => `revoke:${passkeyId}`;
|
|
12
12
|
export const APPROVALS_OFF_PRESENCE = "approvals-off";
|
|
13
|
+
// Writing a workspace's standing rules as Saad.
|
|
14
|
+
export const rulesPresence = (workspaceId: string) => `rules:${workspaceId}`;
|
|
13
15
|
|
|
14
16
|
export function presenceBinding(b: {
|
|
15
17
|
purpose?: PresencePurpose;
|
|
@@ -36,6 +38,9 @@ export function presenceBinding(b: {
|
|
|
36
38
|
return "enroll";
|
|
37
39
|
case "approvals-off":
|
|
38
40
|
return APPROVALS_OFF_PRESENCE;
|
|
41
|
+
case "rules":
|
|
42
|
+
if (!b.workspaceId) throw new PresenceError("Which workspace?");
|
|
43
|
+
return rulesPresence(b.workspaceId);
|
|
39
44
|
case "revoke":
|
|
40
45
|
if (!b.passkeyId) throw new PresenceError("Which passkey?");
|
|
41
46
|
return revokePresence(b.passkeyId);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's standing rules: what its orchestrator must keep knowing
|
|
3
|
+
* across fresh starts ("never merge #111", "briefs must not name
|
|
4
|
+
* competitors"). The orchestrator writes them with rule_add and rule_remove,
|
|
5
|
+
* Saad in Settings → Workspaces, and every start reads them into its brief.
|
|
6
|
+
* They restrict and inform; none can approve anything. A removed rule stays
|
|
7
|
+
* in the table, marked.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { db } from "../db";
|
|
11
|
+
import { redact } from "./untrusted";
|
|
12
|
+
|
|
13
|
+
export type RuleAuthor = "orchestrator" | "saad";
|
|
14
|
+
|
|
15
|
+
export interface RuleRow {
|
|
16
|
+
id: number;
|
|
17
|
+
workspace_id: string;
|
|
18
|
+
text: string;
|
|
19
|
+
added_by: RuleAuthor;
|
|
20
|
+
created_at: string;
|
|
21
|
+
updated_at: string;
|
|
22
|
+
removed_at: string | null;
|
|
23
|
+
removed_by: RuleAuthor | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Kept small: every one is in every start's prompt.
|
|
27
|
+
export const MAX_RULES = 25;
|
|
28
|
+
export const MAX_RULE_CHARS = 300;
|
|
29
|
+
|
|
30
|
+
function clean(text: string): string {
|
|
31
|
+
const body = redact(text).replace(/\s+/g, " ").trim();
|
|
32
|
+
if (!body) throw new Error("The rule is empty");
|
|
33
|
+
if (body.length > MAX_RULE_CHARS)
|
|
34
|
+
throw new Error(`A rule is at most ${MAX_RULE_CHARS} characters`);
|
|
35
|
+
return body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listRules(workspaceId: string): RuleRow[] {
|
|
39
|
+
return db
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT * FROM orchestrator_rules
|
|
42
|
+
WHERE workspace_id = ? AND removed_at IS NULL ORDER BY id`
|
|
43
|
+
)
|
|
44
|
+
.all(workspaceId) as RuleRow[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRule(workspaceId: string, id: number): RuleRow | null {
|
|
48
|
+
return (
|
|
49
|
+
(db
|
|
50
|
+
.prepare(
|
|
51
|
+
`SELECT * FROM orchestrator_rules
|
|
52
|
+
WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
|
|
53
|
+
)
|
|
54
|
+
.get(id, workspaceId) as RuleRow | undefined) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The same text twice is the rule already there.
|
|
59
|
+
export function addRule(
|
|
60
|
+
workspaceId: string,
|
|
61
|
+
text: string,
|
|
62
|
+
by: RuleAuthor
|
|
63
|
+
): RuleRow {
|
|
64
|
+
const body = clean(text);
|
|
65
|
+
return db.transaction(() => {
|
|
66
|
+
const rules = listRules(workspaceId);
|
|
67
|
+
const same = rules.find((r) => r.text === body);
|
|
68
|
+
// Saad writing one the orchestrator kept makes it his.
|
|
69
|
+
if (same && by === "saad" && same.added_by !== "saad") {
|
|
70
|
+
db.prepare(
|
|
71
|
+
`UPDATE orchestrator_rules SET added_by = 'saad', updated_at = datetime('now')
|
|
72
|
+
WHERE id = ?`
|
|
73
|
+
).run(same.id);
|
|
74
|
+
return getRule(workspaceId, same.id)!;
|
|
75
|
+
}
|
|
76
|
+
if (same) return same;
|
|
77
|
+
if (rules.length >= MAX_RULES)
|
|
78
|
+
throw new Error(
|
|
79
|
+
`This workspace has ${MAX_RULES} rules already; remove one first`
|
|
80
|
+
);
|
|
81
|
+
const { lastInsertRowid } = db
|
|
82
|
+
.prepare(
|
|
83
|
+
`INSERT INTO orchestrator_rules (workspace_id, text, added_by) VALUES (?, ?, ?)`
|
|
84
|
+
)
|
|
85
|
+
.run(workspaceId, body, by);
|
|
86
|
+
return getRule(workspaceId, Number(lastInsertRowid))!;
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateRule(
|
|
91
|
+
workspaceId: string,
|
|
92
|
+
id: number,
|
|
93
|
+
text: string
|
|
94
|
+
): RuleRow {
|
|
95
|
+
const body = clean(text);
|
|
96
|
+
const { changes } = db
|
|
97
|
+
.prepare(
|
|
98
|
+
`UPDATE orchestrator_rules SET text = ?, updated_at = datetime('now')
|
|
99
|
+
WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
|
|
100
|
+
)
|
|
101
|
+
.run(body, id, workspaceId);
|
|
102
|
+
if (!changes) throw new Error(`No rule ${id} in this workspace`);
|
|
103
|
+
return getRule(workspaceId, id)!;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function removeRule(
|
|
107
|
+
workspaceId: string,
|
|
108
|
+
id: number,
|
|
109
|
+
by: RuleAuthor
|
|
110
|
+
): RuleRow {
|
|
111
|
+
const rule = getRule(workspaceId, id);
|
|
112
|
+
if (!rule) throw new Error(`No rule ${id} in this workspace`);
|
|
113
|
+
// Saad's restrictions hold until he lifts them: text the orchestrator
|
|
114
|
+
// read can't talk it into removing one.
|
|
115
|
+
if (by === "orchestrator" && rule.added_by === "saad")
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Rule ${id} is Saad's: only he removes it, in Settings → Workspaces`
|
|
118
|
+
);
|
|
119
|
+
db.prepare(
|
|
120
|
+
`UPDATE orchestrator_rules SET removed_at = datetime('now'), removed_by = ?
|
|
121
|
+
WHERE id = ?`
|
|
122
|
+
).run(by, id);
|
|
123
|
+
return rule;
|
|
124
|
+
}
|
|
@@ -15,6 +15,7 @@ import { sessionFacts } from "./facts";
|
|
|
15
15
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
16
16
|
import { resolveStaleAsks } from "./ask-settle";
|
|
17
17
|
import { addNote } from "./notes";
|
|
18
|
+
import { addRule, removeRule } from "./rules";
|
|
18
19
|
import { askSaad } from "./ask-tool";
|
|
19
20
|
import { listOrchestratorPeers, messageOrchestrator } from "./cross";
|
|
20
21
|
import { PAUSED_REFUSAL } from "./pause";
|
|
@@ -122,6 +123,16 @@ export async function runTool(
|
|
|
122
123
|
case "note":
|
|
123
124
|
addNote(w, p(tool, raw).text);
|
|
124
125
|
return "Noted.";
|
|
126
|
+
case "rule_add": {
|
|
127
|
+
const rule = addRule(w, p(tool, raw).text, "orchestrator");
|
|
128
|
+
addNote(w, `Standing rule ${rule.id}: ${rule.text}`);
|
|
129
|
+
return `Rule ${rule.id} kept: it's in your brief on every start.`;
|
|
130
|
+
}
|
|
131
|
+
case "rule_remove": {
|
|
132
|
+
const rule = removeRule(w, p(tool, raw).id, "orchestrator");
|
|
133
|
+
addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
|
|
134
|
+
return `Rule ${rule.id} removed.`;
|
|
135
|
+
}
|
|
125
136
|
case "review": {
|
|
126
137
|
const a = p(tool, raw);
|
|
127
138
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { BRANCH_NAME } from "../git";
|
|
6
6
|
import { RAISED_KINDS } from "./asks";
|
|
7
|
+
import { MAX_RULE_CHARS } from "./rules";
|
|
7
8
|
|
|
8
9
|
const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
|
|
9
10
|
const session = ref("Session name, project/name, or id from sessions");
|
|
@@ -100,6 +101,15 @@ export const TOOL_SHAPES = {
|
|
|
100
101
|
stop: { session },
|
|
101
102
|
done: { session },
|
|
102
103
|
note: { text: z.string().trim().min(1).max(2000) },
|
|
104
|
+
rule_add: {
|
|
105
|
+
text: z
|
|
106
|
+
.string()
|
|
107
|
+
.trim()
|
|
108
|
+
.min(1)
|
|
109
|
+
.max(MAX_RULE_CHARS)
|
|
110
|
+
.describe("The rule, one sentence"),
|
|
111
|
+
},
|
|
112
|
+
rule_remove: { id: z.number().int().positive().describe("The rule's id") },
|
|
103
113
|
review: {
|
|
104
114
|
target: ref("The task, or its PR (#12 or URL)"),
|
|
105
115
|
fresh: z
|
|
@@ -72,6 +72,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
72
72
|
stop: "Stop a session's agent, keeping its worktree and branch.",
|
|
73
73
|
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
|
|
74
74
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
|
75
|
+
rule_add:
|
|
76
|
+
'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
|
|
77
|
+
rule_remove:
|
|
78
|
+
"Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
|
|
75
79
|
review:
|
|
76
80
|
"Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
|
|
77
81
|
sign_off:
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
orchestratorToolsDigest,
|
|
10
10
|
} from "./tools";
|
|
11
11
|
import type { RoleExtras } from "../chat/worker/extras";
|
|
12
|
+
import { refreshSettings } from "../chat/refresh";
|
|
12
13
|
|
|
13
14
|
// What an orchestrator's chat worker starts with on top of any chat: its
|
|
14
15
|
// brief, its tools, and permissions fixed by its role.
|
|
@@ -29,5 +30,9 @@ export async function orchestratorExtras(
|
|
|
29
30
|
},
|
|
30
31
|
...ORCHESTRATOR_PERMISSIONS,
|
|
31
32
|
toolsDigest: orchestratorToolsDigest(),
|
|
33
|
+
refresh: {
|
|
34
|
+
atTokens: refreshSettings().orchestratorTokens,
|
|
35
|
+
role: "orchestrator",
|
|
36
|
+
},
|
|
32
37
|
};
|
|
33
38
|
}
|
package/lib/security/presence.ts
CHANGED
package/package.json
CHANGED
package/styles/themes.css
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
Cool white with a violet accent
|
|
25
25
|
=========================================================================== */
|
|
26
26
|
:root {
|
|
27
|
+
/* Find in a conversation: every match, and the one it's on. */
|
|
28
|
+
--find-match: 48 96% 53% / 0.45;
|
|
29
|
+
--find-current: 25 95% 53%;
|
|
30
|
+
--find-current-foreground: 0 0% 0%;
|
|
27
31
|
--background: 240 10% 95.5%;
|
|
28
32
|
--foreground: 240 12% 14%;
|
|
29
33
|
--card: 0 0% 100%;
|
|
@@ -287,6 +291,7 @@
|
|
|
287
291
|
Blue-black with a violet accent
|
|
288
292
|
=========================================================================== */
|
|
289
293
|
.dark {
|
|
294
|
+
--find-match: 48 96% 53% / 0.35;
|
|
290
295
|
--background: 240 18% 3.5%;
|
|
291
296
|
--foreground: 240 10% 93%;
|
|
292
297
|
--card: 240 14% 6%;
|