symbio 0.4.0 → 0.6.0
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/CHANGELOG.md +32 -0
- package/README.md +16 -7
- package/dist/server/api.js +33 -1
- package/dist/server/connections.js +64 -7
- package/dist/server/docs.js +49 -2
- package/dist/server/guard.js +21 -0
- package/dist/server/images.js +54 -0
- package/dist/server/mcp.js +2 -2
- package/dist/server/prompts.js +76 -8
- package/dist/server/remote.js +65 -23
- package/dist/server/rules.js +191 -0
- package/dist/server/store.js +5 -0
- package/dist/server/team.js +98 -2
- package/dist/server/tools.js +125 -7
- package/dist/server/updates.js +33 -0
- package/dist/server/workspaces.js +14 -0
- package/dist/web/assets/index-CmYLMQvL.js +53 -0
- package/dist/web/assets/{index-BM4oACfZ.css → index-CtcmbMmw.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DhB0EhFQ.js +0 -52
package/dist/server/remote.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Actions agents take through connections (SSH commands and uploads, git push/pull,
|
|
2
|
-
// GitHub
|
|
3
|
-
// only see results. Connections set to "ask" queue an approval for
|
|
4
|
-
// result is delivered to the agent as a message once approved.
|
|
2
|
+
// GitHub and Azure DevOps APIs). Symbio runs them itself with credentials from the
|
|
3
|
+
// vault — agents only see results. Connections set to "ask" queue an approval for
|
|
4
|
+
// the human; the result is delivered to the agent as a message once approved.
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { promises as fs, existsSync, realpathSync } from "node:fs";
|
|
@@ -28,13 +28,16 @@ export class RemoteOps {
|
|
|
28
28
|
}
|
|
29
29
|
describeFor(project) {
|
|
30
30
|
const list = this.enabled(project);
|
|
31
|
-
if (list.length === 0)
|
|
32
|
-
return "This project has no connections. If the work needs GitHub or a server, tell the human to add one in the project's
|
|
31
|
+
if (list.length === 0) {
|
|
32
|
+
return "This project has no connections. If the work needs GitHub, Azure DevOps or a server, tell the human to add one in the project's Settings (Connections).";
|
|
33
|
+
}
|
|
33
34
|
return list
|
|
34
35
|
.map(({ conn, link }) => {
|
|
35
36
|
const what = conn.type === "github"
|
|
36
37
|
? `GitHub${conn.account ? ` account ${conn.account}` : ""} — git_push, git_pull, github_api`
|
|
37
|
-
:
|
|
38
|
+
: conn.type === "azure"
|
|
39
|
+
? `Azure DevOps organization ${conn.organization}${conn.account ? ` (as ${conn.account})` : ""} — git_push, git_pull, azure_devops_api`
|
|
40
|
+
: `server ${conn.username}@${conn.host} — remote_exec, remote_upload`;
|
|
38
41
|
return `- "${conn.name}": ${what} (${link.policy === "ask" ? "each action needs the human's approval" : "allowed without approval"})`;
|
|
39
42
|
})
|
|
40
43
|
.join("\n");
|
|
@@ -43,7 +46,7 @@ export class RemoteOps {
|
|
|
43
46
|
async request(project, agent, action, reason) {
|
|
44
47
|
const { conn, link } = this.resolve(project, action.connectionId, action.kind);
|
|
45
48
|
const summary = this.summarize(conn, action, agent);
|
|
46
|
-
const readOnly = action.kind === "github_api" && action.method.toUpperCase() === "GET";
|
|
49
|
+
const readOnly = (action.kind === "github_api" || action.kind === "azure_devops_api") && action.method.toUpperCase() === "GET";
|
|
47
50
|
if (link.policy === "allow" || readOnly) {
|
|
48
51
|
return this.execute(project, agent, conn, action, summary, reason);
|
|
49
52
|
}
|
|
@@ -106,9 +109,17 @@ export class RemoteOps {
|
|
|
106
109
|
const names = this.enabled(project).map(({ conn }) => conn.name).join(", ") || "none";
|
|
107
110
|
throw new Error(`No connection "${connectionRef}" in this project. Available: ${names}.`);
|
|
108
111
|
}
|
|
109
|
-
const needs = kind.startsWith("remote_")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const needs = kind.startsWith("remote_")
|
|
113
|
+
? ["server"]
|
|
114
|
+
: kind === "github_api"
|
|
115
|
+
? ["github"]
|
|
116
|
+
: kind === "azure_devops_api"
|
|
117
|
+
? ["azure"]
|
|
118
|
+
: ["github", "azure"]; // git_push / git_pull
|
|
119
|
+
if (!needs.includes(found.conn.type)) {
|
|
120
|
+
const label = (t) => (t === "azure" ? "Azure DevOps" : t === "github" ? "GitHub" : "server");
|
|
121
|
+
throw new Error(`"${found.conn.name}" is a ${label(found.conn.type)} connection; ${kind} needs a ${needs.map(label).join(" or ")} connection.`);
|
|
122
|
+
}
|
|
112
123
|
return found;
|
|
113
124
|
}
|
|
114
125
|
summarize(conn, a, agent) {
|
|
@@ -123,6 +134,8 @@ export class RemoteOps {
|
|
|
123
134
|
return `git pull ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} into ${agent.name}'s workspace using ${conn.name}`;
|
|
124
135
|
case "github_api":
|
|
125
136
|
return `GitHub API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
|
|
137
|
+
case "azure_devops_api":
|
|
138
|
+
return `Azure DevOps API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
async execute(project, agent, conn, a, summary, reason) {
|
|
@@ -163,12 +176,11 @@ export class RemoteOps {
|
|
|
163
176
|
}
|
|
164
177
|
case "git_push":
|
|
165
178
|
case "git_pull": {
|
|
166
|
-
const
|
|
179
|
+
const gitConn = conn;
|
|
180
|
+
const token = await this.connections.githubToken(gitConn);
|
|
167
181
|
const remote = a.remote ?? "origin";
|
|
168
182
|
const url = remote.startsWith("https://") ? remote : await gitOut(agent.directory, ["remote", "get-url", remote]);
|
|
169
|
-
|
|
170
|
-
throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
|
|
171
|
-
}
|
|
183
|
+
const auth = gitAuth(gitConn, url, token);
|
|
172
184
|
const branch = a.branch ?? (await gitOut(agent.directory, ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
173
185
|
// The agent controls this repository, so its config and hooks are untrusted: a hook
|
|
174
186
|
// would see the token, a proxy or URL rewrite would send it elsewhere.
|
|
@@ -177,7 +189,7 @@ export class RemoteOps {
|
|
|
177
189
|
const safe = ["-c", `core.hooksPath=${noHooks}`, "-c", "http.proxy=", "-c", "http.sslVerify=true", "-c", "credential.helper="];
|
|
178
190
|
// The token goes in an HTTP header via environment-only git config: never in
|
|
179
191
|
// the command line, the repo config or the agent's view.
|
|
180
|
-
const header =
|
|
192
|
+
const { headerKey, header } = auth;
|
|
181
193
|
const git = (args, withToken) => run("git", [...safe, ...args], {
|
|
182
194
|
cwd: agent.directory,
|
|
183
195
|
windowsHide: true,
|
|
@@ -185,9 +197,7 @@ export class RemoteOps {
|
|
|
185
197
|
env: {
|
|
186
198
|
...process.env,
|
|
187
199
|
GIT_TERMINAL_PROMPT: "0",
|
|
188
|
-
...(withToken
|
|
189
|
-
? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: header }
|
|
190
|
-
: {}),
|
|
200
|
+
...(withToken ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: headerKey, GIT_CONFIG_VALUE_0: header } : {}),
|
|
191
201
|
},
|
|
192
202
|
}).catch((err) => {
|
|
193
203
|
throw new Error(scrub(String(err.stderr || err.message), token));
|
|
@@ -201,8 +211,11 @@ export class RemoteOps {
|
|
|
201
211
|
const merged = await git(["merge", "--no-edit", "FETCH_HEAD"], false);
|
|
202
212
|
return scrub(`${fetched.stdout}${fetched.stderr}${merged.stdout}${merged.stderr}`.trim() || "done", token);
|
|
203
213
|
}
|
|
204
|
-
case "github_api":
|
|
205
|
-
|
|
214
|
+
case "github_api":
|
|
215
|
+
case "azure_devops_api": {
|
|
216
|
+
const data = a.kind === "github_api"
|
|
217
|
+
? await this.connections.githubRequest(conn, a.method.toUpperCase(), a.path, a.body)
|
|
218
|
+
: await this.connections.azureRequest(conn, a.method.toUpperCase(), a.path, a.body);
|
|
206
219
|
const text = JSON.stringify(data, null, 2) ?? "(no content)";
|
|
207
220
|
return text.length > 20_000 ? `${text.slice(0, 20_000)}\n… (truncated)` : text;
|
|
208
221
|
}
|
|
@@ -258,7 +271,7 @@ export async function refuseRiskyGitConfig(dir) {
|
|
|
258
271
|
.filter(([scope, key]) => (scope === "local" || scope === "worktree") && key && RISKY_GIT_CONFIG.some((re) => re.test(key.toLowerCase())))
|
|
259
272
|
.map(([, key]) => key);
|
|
260
273
|
if (risky.length) {
|
|
261
|
-
throw new Error(`Refusing to use
|
|
274
|
+
throw new Error(`Refusing to use the connection's credentials: this repository's git config sets ${risky.join(", ")}, which could redirect or capture them. Remove ${risky.length > 1 ? "these settings" : "it"} (git config --unset <name>) and try again.`);
|
|
262
275
|
}
|
|
263
276
|
}
|
|
264
277
|
async function gitOut(cwd, args) {
|
|
@@ -266,6 +279,35 @@ async function gitOut(cwd, args) {
|
|
|
266
279
|
return stdout.trim();
|
|
267
280
|
}
|
|
268
281
|
function scrub(text, secret) {
|
|
269
|
-
|
|
270
|
-
|
|
282
|
+
let out = text.split(secret).join("***");
|
|
283
|
+
for (const user of ["x-access-token:", ":"])
|
|
284
|
+
out = out.split(Buffer.from(`${user}${secret}`).toString("base64")).join("***");
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* How to send a connection's token to a git remote, after checking the remote belongs to it:
|
|
289
|
+
* a GitHub HTTPS URL for GitHub; for Azure DevOps, a dev.azure.com (or older visualstudio.com)
|
|
290
|
+
* URL of the connection's own organization, so its token never goes to someone else's.
|
|
291
|
+
*/
|
|
292
|
+
export function gitAuth(conn, url, token) {
|
|
293
|
+
if (conn.type === "github") {
|
|
294
|
+
if (!/^https:\/\/github\.com\//i.test(url)) {
|
|
295
|
+
throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
|
|
296
|
+
}
|
|
297
|
+
return { headerKey: "http.https://github.com/.extraheader", header: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}` };
|
|
298
|
+
}
|
|
299
|
+
const devAzure = /^https:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\//i.exec(url);
|
|
300
|
+
const legacy = /^https:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com\//i.exec(url);
|
|
301
|
+
if (!devAzure && !legacy) {
|
|
302
|
+
throw new Error(`The remote must be an Azure DevOps HTTPS URL (got ${url || "nothing"}). ` +
|
|
303
|
+
(/^git@|^ssh:/i.test(url)
|
|
304
|
+
? "It's an SSH remote; switch it to HTTPS: git remote set-url origin https://dev.azure.com/<organization>/<project>/_git/<repository>"
|
|
305
|
+
: "Add one with: git remote add origin https://dev.azure.com/<organization>/<project>/_git/<repository>"));
|
|
306
|
+
}
|
|
307
|
+
const org = decodeURIComponent((devAzure ?? legacy)[1]);
|
|
308
|
+
if (org.toLowerCase() !== conn.organization.toLowerCase()) {
|
|
309
|
+
throw new Error(`This remote belongs to the Azure DevOps organization "${org}", but "${conn.name}" is for "${conn.organization}". Use a connection for "${org}".`);
|
|
310
|
+
}
|
|
311
|
+
const host = devAzure ? "https://dev.azure.com/" : `https://${legacy[1]}.visualstudio.com/`;
|
|
312
|
+
return { headerKey: `http.${host}.extraheader`, header: `Authorization: Basic ${Buffer.from(`:${token}`).toString("base64")}` };
|
|
271
313
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Rules register: the project's rules, each with where it came from and how it's met. Kept as
|
|
2
|
+
// JSON next to the project docs so the Docs tab can show it as a filterable index. Agents change
|
|
3
|
+
// it only through Symbio's tools (validated, stable ids), never by editing the file.
|
|
4
|
+
//
|
|
5
|
+
// Domain-neutral on purpose: a "rule" is anything the project must respect (a requirement, a
|
|
6
|
+
// decision, a style rule, a constraint), whatever the project is about.
|
|
7
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { docsRoot } from "./docs.js";
|
|
10
|
+
export const CONFIRMATIONS = ["found", "checked-by-agent", "checked-by-human", "contested"];
|
|
11
|
+
export const STATUSES = ["done", "partial", "missing", "not-applicable"];
|
|
12
|
+
const empty = () => ({ areas: [], sources: [], authorities: [], rules: [] });
|
|
13
|
+
export function registerPath(project) {
|
|
14
|
+
return path.join(docsRoot(project), project.docsLocation === "private" ? "rules.json" : path.join("docs", "rules.json"));
|
|
15
|
+
}
|
|
16
|
+
export async function readRegister(project) {
|
|
17
|
+
const file = registerPath(project);
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return empty();
|
|
20
|
+
const data = JSON.parse(await fs.readFile(file, "utf8"));
|
|
21
|
+
return { areas: data.areas ?? [], sources: data.sources ?? [], authorities: data.authorities ?? [], rules: data.rules ?? [] };
|
|
22
|
+
}
|
|
23
|
+
// Several agents may write at once: one write at a time per file.
|
|
24
|
+
const queues = new Map();
|
|
25
|
+
async function change(project, fn) {
|
|
26
|
+
const file = registerPath(project);
|
|
27
|
+
const run = (queues.get(file) ?? Promise.resolve()).then(async () => {
|
|
28
|
+
const reg = await readRegister(project);
|
|
29
|
+
const result = fn(reg);
|
|
30
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
const tmp = `${file}.tmp`;
|
|
32
|
+
await fs.writeFile(tmp, JSON.stringify(reg, null, 2) + "\n");
|
|
33
|
+
await fs.rename(tmp, file);
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
queues.set(file, run.catch(() => { }));
|
|
37
|
+
return run;
|
|
38
|
+
}
|
|
39
|
+
const CODE = /^[A-Za-z0-9][A-Za-z0-9_.]{0,15}$/;
|
|
40
|
+
function code(value, what) {
|
|
41
|
+
const s = String(value ?? "").trim();
|
|
42
|
+
if (!CODE.test(s))
|
|
43
|
+
throw new Error(`${what} must be a short code (letters, digits, dots; up to 16), e.g. "AP" or "MA".`);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
const text = (value) => (value === undefined || value === null ? undefined : String(value).trim() || undefined);
|
|
47
|
+
export async function saveArea(project, input) {
|
|
48
|
+
const id = code(input.id, "The area id").toUpperCase();
|
|
49
|
+
const name = text(input.name);
|
|
50
|
+
if (!name)
|
|
51
|
+
throw new Error("Give the area a name.");
|
|
52
|
+
const stages = Array.isArray(input.stages)
|
|
53
|
+
? input.stages.map((s, i) => {
|
|
54
|
+
const stageName = text(s?.name ?? s);
|
|
55
|
+
if (!stageName)
|
|
56
|
+
throw new Error(`Stage ${i + 1} needs a name.`);
|
|
57
|
+
return { id: text(s?.id) ?? String.fromCharCode(65 + i), name: stageName };
|
|
58
|
+
})
|
|
59
|
+
: undefined;
|
|
60
|
+
return change(project, (reg) => {
|
|
61
|
+
if (reg.sources.some((s) => s.id === id))
|
|
62
|
+
throw new Error(`"${id}" is already a source code; an area prefix must differ from every source code.`);
|
|
63
|
+
const existing = reg.areas.find((a) => a.id === id);
|
|
64
|
+
const area = { id, name, group: text(input.group), description: text(input.description), stages: stages ?? existing?.stages ?? [] };
|
|
65
|
+
if (existing)
|
|
66
|
+
Object.assign(existing, area);
|
|
67
|
+
else
|
|
68
|
+
reg.areas.push(area);
|
|
69
|
+
return `${existing ? "Updated" : "Added"} area ${id} "${name}"${area.stages.length ? ` with stages ${area.stages.map((s) => `${s.id}. ${s.name}`).join("; ")}` : ""}.`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function saveSource(project, input) {
|
|
73
|
+
const id = code(input.id, "The source id");
|
|
74
|
+
const name = text(input.name);
|
|
75
|
+
if (!name)
|
|
76
|
+
throw new Error("Give the source a name.");
|
|
77
|
+
const authority = input.authority === undefined || input.authority === "" ? undefined : code(input.authority, "The authority");
|
|
78
|
+
return change(project, (reg) => {
|
|
79
|
+
if (reg.areas.some((a) => a.id === id))
|
|
80
|
+
throw new Error(`"${id}" is already an area prefix; a source code must differ from every area prefix.`);
|
|
81
|
+
const source = { id, name, file: text(input.file)?.replace(/\\/g, "/"), authority };
|
|
82
|
+
const existing = reg.sources.find((s) => s.id === id);
|
|
83
|
+
if (existing)
|
|
84
|
+
Object.assign(existing, source);
|
|
85
|
+
else
|
|
86
|
+
reg.sources.push(source);
|
|
87
|
+
const authorityName = text(input.authority_name);
|
|
88
|
+
if (authority && authorityName) {
|
|
89
|
+
const known = reg.authorities.find((a) => a.id === authority);
|
|
90
|
+
if (known)
|
|
91
|
+
known.name = authorityName;
|
|
92
|
+
else
|
|
93
|
+
reg.authorities.push({ id: authority, name: authorityName });
|
|
94
|
+
}
|
|
95
|
+
return `${existing ? "Updated" : "Added"} source ${id} "${name}".`;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Creates a rule (id assigned in its area) or updates one; only the given fields change. */
|
|
99
|
+
export async function saveRule(project, input, by) {
|
|
100
|
+
return change(project, (reg) => {
|
|
101
|
+
const id = text(input.id)?.toUpperCase();
|
|
102
|
+
const existing = id ? reg.rules.find((r) => r.id === id) : undefined;
|
|
103
|
+
if (id && !existing)
|
|
104
|
+
throw new Error(`No rule ${id}. Leave id out to add a new rule (Symbio assigns the next id in its area).`);
|
|
105
|
+
const areaId = text(input.area)?.toUpperCase() ?? existing?.area;
|
|
106
|
+
const area = reg.areas.find((a) => a.id === areaId);
|
|
107
|
+
if (!area) {
|
|
108
|
+
throw new Error(areaId
|
|
109
|
+
? `No area ${areaId}. Define it first with save_rule_area.${reg.areas.length ? ` Areas: ${reg.areas.map((a) => a.id).join(", ")}.` : ""}`
|
|
110
|
+
: "Say which area the rule belongs to (area).");
|
|
111
|
+
}
|
|
112
|
+
const stage = input.stage === undefined ? existing?.stage : text(input.stage);
|
|
113
|
+
if (stage && !area.stages.some((s) => s.id === stage)) {
|
|
114
|
+
throw new Error(`Area ${area.id} has no stage "${stage}". Stages: ${area.stages.map((s) => s.id).join(", ") || "none (define them with save_rule_area)"}.`);
|
|
115
|
+
}
|
|
116
|
+
const ruleText = input.text === undefined ? existing?.text : text(input.text);
|
|
117
|
+
if (!ruleText)
|
|
118
|
+
throw new Error("Say what the rule is (text).");
|
|
119
|
+
const confirmation = (input.confirmation ?? existing?.confirmation ?? "found");
|
|
120
|
+
if (!CONFIRMATIONS.includes(confirmation))
|
|
121
|
+
throw new Error(`confirmation must be one of: ${CONFIRMATIONS.join(", ")}.`);
|
|
122
|
+
const status = (input.status ?? existing?.status ?? "missing");
|
|
123
|
+
if (!STATUSES.includes(status))
|
|
124
|
+
throw new Error(`status must be one of: ${STATUSES.join(", ")}.`);
|
|
125
|
+
let citations = existing?.citations ?? [];
|
|
126
|
+
if (input.citations !== undefined) {
|
|
127
|
+
if (!Array.isArray(input.citations))
|
|
128
|
+
throw new Error("citations must be a list of {source, location, excerpt}.");
|
|
129
|
+
citations = input.citations.map((c) => {
|
|
130
|
+
const source = code(c?.source, "A citation's source");
|
|
131
|
+
if (!reg.sources.some((s) => s.id === source))
|
|
132
|
+
throw new Error(`Unknown source "${source}". Define it first with save_rule_source.`);
|
|
133
|
+
return { source, location: text(c?.location), excerpt: text(c?.excerpt) };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const imposedBy = input.imposed_by === undefined ? existing?.imposedBy : text(input.imposed_by);
|
|
137
|
+
const supersededBy = input.superseded_by === undefined ? existing?.supersededBy : text(input.superseded_by)?.toUpperCase();
|
|
138
|
+
if (supersededBy && !reg.rules.some((r) => r.id === supersededBy))
|
|
139
|
+
throw new Error(`No rule ${supersededBy} to supersede this one.`);
|
|
140
|
+
const rule = {
|
|
141
|
+
id: existing?.id ?? nextId(reg, area.id),
|
|
142
|
+
area: area.id,
|
|
143
|
+
stage,
|
|
144
|
+
text: ruleText,
|
|
145
|
+
action: input.action === undefined ? existing?.action : text(input.action),
|
|
146
|
+
citations,
|
|
147
|
+
imposedBy,
|
|
148
|
+
confirmation,
|
|
149
|
+
status,
|
|
150
|
+
current: input.current === undefined ? existing?.current : text(input.current),
|
|
151
|
+
check: input.check === undefined ? existing?.check : text(input.check),
|
|
152
|
+
supersededBy,
|
|
153
|
+
updatedAt: Date.now(),
|
|
154
|
+
updatedBy: by,
|
|
155
|
+
};
|
|
156
|
+
if (existing)
|
|
157
|
+
Object.assign(existing, rule);
|
|
158
|
+
else
|
|
159
|
+
reg.rules.push(rule);
|
|
160
|
+
const unsourced = rule.citations.length === 0 ? " It has no source yet: add one when you find it." : "";
|
|
161
|
+
return `${existing ? "Updated" : "Added"} ${rule.id}.${unsourced}`;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Numbers are never reused: the next one is above every number the area ever had. */
|
|
165
|
+
function nextId(reg, area) {
|
|
166
|
+
const used = reg.rules.filter((r) => r.id.startsWith(`${area}-`)).map((r) => Number(r.id.slice(area.length + 1)) || 0);
|
|
167
|
+
return `${area}-${String(Math.max(0, ...used) + 1).padStart(3, "0")}`;
|
|
168
|
+
}
|
|
169
|
+
/** A compact listing for agents. */
|
|
170
|
+
export async function listRules(project, filter) {
|
|
171
|
+
const reg = await readRegister(project);
|
|
172
|
+
const q = text(filter.query)?.toLowerCase();
|
|
173
|
+
const area = text(filter.area)?.toUpperCase();
|
|
174
|
+
const status = text(filter.status);
|
|
175
|
+
const rules = reg.rules.filter((r) => (!area || r.area === area) &&
|
|
176
|
+
(!status || r.status === status) &&
|
|
177
|
+
(!q || [r.id, r.text, r.current, r.action, ...r.citations.map((c) => `${c.source} ${c.location ?? ""} ${c.excerpt ?? ""}`)].some((s) => s?.toLowerCase().includes(q))));
|
|
178
|
+
const head = [
|
|
179
|
+
`Areas: ${reg.areas.map((a) => `${a.id} (${a.name}${a.stages.length ? `; stages ${a.stages.map((s) => `${s.id}. ${s.name}`).join(", ")}` : ""})`).join(" · ") || "none yet"}`,
|
|
180
|
+
`Sources: ${reg.sources.map((s) => `${s.id} = ${s.name}${s.file ? ` [${s.file}]` : ""}`).join(" · ") || "none yet"}`,
|
|
181
|
+
];
|
|
182
|
+
if (!rules.length)
|
|
183
|
+
return [...head, reg.rules.length ? "No rules match." : "No rules yet."].join("\n");
|
|
184
|
+
const shown = rules.slice(0, 200);
|
|
185
|
+
return [
|
|
186
|
+
...head,
|
|
187
|
+
`${rules.length} rule(s)${rules.length > shown.length ? ` (first ${shown.length}; narrow the filter for the rest)` : ""}:`,
|
|
188
|
+
...shown.map((r) => `- ${r.id}${r.stage ? ` [${r.stage}]` : ""} ${r.status}/${r.confirmation}${r.supersededBy ? ` superseded by ${r.supersededBy}` : ""}: ${r.text.replace(/\s+/g, " ").slice(0, 220)}` +
|
|
189
|
+
(r.citations.length ? ` — ${r.citations.map((c) => `${c.source}${c.location ? ` ${c.location}` : ""}`).join(", ")}` : " — no source")),
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
package/dist/server/store.js
CHANGED
|
@@ -84,6 +84,7 @@ export class Store {
|
|
|
84
84
|
allowedModels: input.allowedModels,
|
|
85
85
|
coordinatorModel: input.coordinatorModel,
|
|
86
86
|
docsLocation: input.docsLocation,
|
|
87
|
+
rulesRegister: input.rulesRegister,
|
|
87
88
|
origin: input.origin,
|
|
88
89
|
inspectionSummary: input.inspectionSummary,
|
|
89
90
|
kickedOff: false,
|
|
@@ -93,6 +94,10 @@ export class Store {
|
|
|
93
94
|
createdAt: Date.now(),
|
|
94
95
|
};
|
|
95
96
|
project.directory = directory;
|
|
97
|
+
// A reopened project keeps its history and team, but takes the options just chosen in the
|
|
98
|
+
// dialog that have no history behind them.
|
|
99
|
+
if (onDisk && input.rulesRegister !== undefined)
|
|
100
|
+
project.rulesRegister = input.rulesRegister;
|
|
96
101
|
this.projects.set(project.id, project);
|
|
97
102
|
await this.saveProject(project);
|
|
98
103
|
await this.saveGlobal();
|
package/dist/server/team.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { AssetRegister } from "./assets.js";
|
|
8
|
-
import { AGENT_PERMISSIONS, wildcardMatch } from "./guard.js";
|
|
8
|
+
import { AGENT_PERMISSIONS, imageReadRefusal, wildcardMatch } from "./guard.js";
|
|
9
9
|
import { bus } from "./bus.js";
|
|
10
10
|
import { privateDocsInstruction } from "./docs.js";
|
|
11
11
|
import { parseModel } from "./opencode/client.js";
|
|
@@ -17,7 +17,7 @@ import { accountChildUsage, accountUsage, emptyUsage, sessionUsage } from "./cos
|
|
|
17
17
|
import { AGENT_UPDATES, unseenUpdates, updatesNote } from "./updates.js";
|
|
18
18
|
import { cleanBuildOutputs, dailyBackup, gb, helperFolders, lastBackup, unpushedWork } from "./housekeeping.js";
|
|
19
19
|
import { coordinatorTools, workerTools } from "./tools.js";
|
|
20
|
-
import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
20
|
+
import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, reattachWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
21
21
|
const STUCK_AFTER_MS = Number(process.env.SYMBIO_STUCK_MINUTES ?? 10) * 60_000;
|
|
22
22
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
23
23
|
// Loop guard: max conversational messages between the same pair in the window (per
|
|
@@ -85,6 +85,8 @@ export class Team {
|
|
|
85
85
|
limitNoticed = new Map(); // pair → when the human was last told
|
|
86
86
|
failedModels = new Map(); // agentId → model → failedAt
|
|
87
87
|
fallbackBusy = new Set();
|
|
88
|
+
/** When each agent last got a fresh session because its conversation was too large to send. */
|
|
89
|
+
oversizedAt = new Map();
|
|
88
90
|
/** agentId → tool call id of an open interactive question. */
|
|
89
91
|
pendingQuestion = new Map();
|
|
90
92
|
/** Connection actions (SSH, git, GitHub API) and their approvals. Set at startup. */
|
|
@@ -268,6 +270,24 @@ export class Team {
|
|
|
268
270
|
: "Symbio: the human set this project's helpers to work in their own copy (own branch) by default. Helpers already in the " +
|
|
269
271
|
"project folder stay there.", false);
|
|
270
272
|
}
|
|
273
|
+
/** The human turned the rules register on or off; every brief mentions it, and core is told. */
|
|
274
|
+
async setRulesRegister(project, on) {
|
|
275
|
+
if (!!project.rulesRegister === on)
|
|
276
|
+
return;
|
|
277
|
+
project.rulesRegister = on;
|
|
278
|
+
await this.store.update(project);
|
|
279
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
280
|
+
for (const agent of project.agents.filter(active))
|
|
281
|
+
await this.refreshInstructions(project, agent).catch(() => { });
|
|
282
|
+
const coordinator = this.coordinator(project);
|
|
283
|
+
if (!coordinator || coordinator.status === "stopped")
|
|
284
|
+
return;
|
|
285
|
+
await this.system(project, coordinator, on
|
|
286
|
+
? "Symbio: the human turned on the rules register for this project. Your instructions now explain it (save_rule, list_rules…). " +
|
|
287
|
+
"Record rules as they come up in the work; if the project already has rules written down somewhere, offer the human to " +
|
|
288
|
+
"bring them into the register as a planned task."
|
|
289
|
+
: "Symbio: the human turned off the rules register for this project. Stop recording rules; what's recorded stays in the file.", false);
|
|
290
|
+
}
|
|
271
291
|
/** Renames a helper. Its branch and folder keep their names; the old name keeps working. */
|
|
272
292
|
async renameAgent(project, agent, newName) {
|
|
273
293
|
if (agent.role === "coordinator")
|
|
@@ -954,6 +974,14 @@ export class Team {
|
|
|
954
974
|
async onPermissionAsked(project, agent, req) {
|
|
955
975
|
const action = String(req.action ?? "");
|
|
956
976
|
const resources = Array.isArray(req.resources) ? req.resources.map(String) : [];
|
|
977
|
+
// Image files: refused at once, with what to use instead (never a question for the human).
|
|
978
|
+
const refusal = imageReadRefusal(action, resources);
|
|
979
|
+
if (refusal) {
|
|
980
|
+
await this.client()
|
|
981
|
+
.request("POST", `/api/session/${agent.sessionId}/permission/${req.id}/reply`, { decision: "reject", message: refusal })
|
|
982
|
+
.catch(() => { });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
957
985
|
// Already answered "always" for this kind of request in this project?
|
|
958
986
|
const allowed = resources.length > 0 && resources.every((r) => project.allowedPatterns.some((a) => a.action === action && wildcardMatch(a.pattern, r)));
|
|
959
987
|
if (allowed) {
|
|
@@ -1236,11 +1264,29 @@ export class Team {
|
|
|
1236
1264
|
}
|
|
1237
1265
|
/** Before starting an agent's turn: make sure its tools and private docs are in place. */
|
|
1238
1266
|
async prepare(project, agent) {
|
|
1267
|
+
await this.reattach(project, agent);
|
|
1239
1268
|
await this.ensureTools(project, agent.directory);
|
|
1240
1269
|
await this.syncPrivateDocs(project, agent);
|
|
1241
1270
|
}
|
|
1271
|
+
/** A helper whose folder disappeared gets it back from its branch (see reattachWorkspace). */
|
|
1272
|
+
async reattach(project, agent) {
|
|
1273
|
+
if (agent.status === "stopped")
|
|
1274
|
+
return;
|
|
1275
|
+
const recreated = await reattachWorkspace(project, agent).catch((err) => {
|
|
1276
|
+
console.error(`[team] could not recreate ${agent.name}'s folder:`, err.message);
|
|
1277
|
+
return false;
|
|
1278
|
+
});
|
|
1279
|
+
if (!recreated)
|
|
1280
|
+
return;
|
|
1281
|
+
const text = `${agent.name}'s folder (${agent.directory}) had disappeared; Symbio recreated it from its branch ${agent.branch}. ` +
|
|
1282
|
+
"Everything it committed is there; changes it hadn't committed were in the folder and are gone.";
|
|
1283
|
+
await this.log(project, { from: "symbio", to: "human", kind: "system", text });
|
|
1284
|
+
await this.system(project, agent, `Symbio: ${text.replace(`${agent.name}'s folder`, "Your folder")} Check your state before continuing.`, false);
|
|
1285
|
+
}
|
|
1242
1286
|
async refreshAll() {
|
|
1243
1287
|
for (const project of this.store.list()) {
|
|
1288
|
+
for (const agent of project.agents.filter(active))
|
|
1289
|
+
await this.reattach(project, agent);
|
|
1244
1290
|
const dirs = new Set(project.agents.filter(active).map((a) => a.directory));
|
|
1245
1291
|
for (const dir of dirs) {
|
|
1246
1292
|
await this.registerTools(project, dir).catch((err) => console.error(`[team] could not register tools for ${project.name} (${dir}):`, err.message));
|
|
@@ -1374,6 +1420,47 @@ export class Team {
|
|
|
1374
1420
|
bus.publish({ type: "agent.stream", projectId: project.id, agentId: agent.id, event: { type: e.type, data: e.data } });
|
|
1375
1421
|
}
|
|
1376
1422
|
}
|
|
1423
|
+
/**
|
|
1424
|
+
* An agent whose conversation got too large to send continues in a fresh session (with the
|
|
1425
|
+
* usual handoff note) and is told why. Once per 15 minutes: if the fresh one fails the same
|
|
1426
|
+
* way, the human is told instead of looping.
|
|
1427
|
+
*/
|
|
1428
|
+
async renewOversized(project, agent, message) {
|
|
1429
|
+
const last = this.oversizedAt.get(agent.id) ?? 0;
|
|
1430
|
+
if (Date.now() - last < 15 * 60_000) {
|
|
1431
|
+
if (agent.problem)
|
|
1432
|
+
return;
|
|
1433
|
+
agent.problem = `Its conversation is too large to send, even after a fresh session (${message}).`;
|
|
1434
|
+
this.setStatus(project, agent, "error");
|
|
1435
|
+
await this.store.update(project);
|
|
1436
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
1437
|
+
await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
this.oversizedAt.set(agent.id, Date.now());
|
|
1441
|
+
this.fallbackBusy.add(agent.id);
|
|
1442
|
+
try {
|
|
1443
|
+
agent.problem = null;
|
|
1444
|
+
if (agent.status !== "idle")
|
|
1445
|
+
this.setStatus(project, agent, "idle");
|
|
1446
|
+
await this.renewSession(project, agent, "its conversation had grown too large to send");
|
|
1447
|
+
await this.system(project, agent, "Symbio: your previous conversation had grown too large to send to the model (the provider said: " +
|
|
1448
|
+
`"${message}"), so you continue in this fresh session; the handoff note above has what carries over. ` +
|
|
1449
|
+
"Usually it's images read in full: each one stays in the conversation and is sent again every turn. " +
|
|
1450
|
+
"To look at an image, read a small copy (scaled down to about 1000 px wide) or have a helper review it. " +
|
|
1451
|
+
"Continue where you left off.");
|
|
1452
|
+
}
|
|
1453
|
+
catch (err) {
|
|
1454
|
+
agent.problem = `Its conversation is too large to send (${message}), and a fresh session couldn't start: ${err.message}. Use New session in its chat.`;
|
|
1455
|
+
this.setStatus(project, agent, "error");
|
|
1456
|
+
await this.store.update(project);
|
|
1457
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
1458
|
+
await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
|
|
1459
|
+
}
|
|
1460
|
+
finally {
|
|
1461
|
+
this.fallbackBusy.delete(agent.id);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1377
1464
|
/**
|
|
1378
1465
|
* When an agent's model fails for provider reasons (quota, outage, timeouts),
|
|
1379
1466
|
* switch it to the next allowed model and tell it to continue.
|
|
@@ -1386,6 +1473,15 @@ export class Team {
|
|
|
1386
1473
|
const status = Number(error?.status ?? 0);
|
|
1387
1474
|
if (/interrupt|abort|cancel/i.test(`${type} ${message}`))
|
|
1388
1475
|
return; // our own interrupts
|
|
1476
|
+
// The conversation itself is too big to send (seen with Copilot: "Timed out reading request
|
|
1477
|
+
// body", after an agent read several large images). Another model won't help; a fresh session will.
|
|
1478
|
+
if (status === 413 || /request body|request size|payload too large|request too large|request entity too large/i.test(message)) {
|
|
1479
|
+
// Still retrying the same oversized request: stop it before moving on.
|
|
1480
|
+
if (interruptFirst)
|
|
1481
|
+
await this.client().interrupt(agent.sessionId).catch(() => { });
|
|
1482
|
+
await this.renewOversized(project, agent, message);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1389
1485
|
const providerProblem = type.startsWith("provider") ||
|
|
1390
1486
|
[401, 402, 403, 404, 408, 429].includes(status) ||
|
|
1391
1487
|
status >= 500 ||
|