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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.6.0]
|
|
9
|
+
|
|
10
|
+
- Azure DevOps connections: add an organization with a personal access token in Settings; agents
|
|
11
|
+
push and pull through it (HTTPS remotes of that organization) and call its REST API with the new
|
|
12
|
+
`azure_devops_api` tool, e.g. to open pull requests. The token never reaches the agent and only
|
|
13
|
+
goes to the connection's own organization.
|
|
14
|
+
- Credentials rule clarified: Symbio's own secrets stay off-limits to agents, but a project's own
|
|
15
|
+
credentials (connection strings, `.env`…) and ones the human gives may be used for the task;
|
|
16
|
+
agents must not copy them into anything committed or shared, and only read from systems the
|
|
17
|
+
human calls read-only or production. Agents had refused legitimate work (querying a database
|
|
18
|
+
with the app's connection string) under the old blanket rule.
|
|
19
|
+
|
|
20
|
+
## [0.5.0]
|
|
21
|
+
|
|
22
|
+
- Docs tab: a folder tree instead of a flat list; search across all docs (any case or accent) that
|
|
23
|
+
opens the doc at the matching line; links and paths in backticks that point to another doc open
|
|
24
|
+
it, with Back; an "On this page" outline for long docs; .txt sources shown with line numbers.
|
|
25
|
+
- Rules register (optional, per project; on by default for new projects): agents record rules with
|
|
26
|
+
their sources, literal excerpts, who imposes them, confirmation and status through new tools, and
|
|
27
|
+
the Docs tab shows them as a filterable index grouped by area and stage. A citation opens its
|
|
28
|
+
source: a text doc at the cited line, a PDF in a new tab at the cited page.
|
|
29
|
+
- Links in docs to PDFs and images open them in a new tab.
|
|
30
|
+
- `view_image`: agents look at images through a scaled-down copy (or a full-resolution crop);
|
|
31
|
+
reading image files directly is refused with a message pointing to it, so images can't bloat a
|
|
32
|
+
conversation.
|
|
33
|
+
- An agent whose conversation becomes too large to send ("Timed out reading request body", often
|
|
34
|
+
after reading full-size images) continues in a fresh session with a handoff note, instead of
|
|
35
|
+
Symbio trying every other model.
|
|
36
|
+
- A helper's folder that was deleted outside Symbio is recreated from its branch (committed work
|
|
37
|
+
kept), and the human and the helper are told.
|
|
38
|
+
- A project reopened from its folder takes the rules register choice made in the New project dialog.
|
|
39
|
+
|
|
8
40
|
## [0.4.0]
|
|
9
41
|
|
|
10
42
|
- Helpers can work in the project folder itself instead of their own copy on a branch: what one
|
package/README.md
CHANGED
|
@@ -56,27 +56,36 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
|
|
|
56
56
|
existing folder before touching it, and proposes a plan of tasks for you to approve.
|
|
57
57
|
- **Helpers that work in parallel without collisions.** Each helper gets its own git worktree and
|
|
58
58
|
branch, and core reviews and merges their work. For step-by-step work (each step using the
|
|
59
|
-
previous one's files), helpers can share the project folder instead, with nothing to merge.
|
|
60
|
-
parallel; one helper is the default.
|
|
59
|
+
previous one's files), helpers can share the project folder instead, with nothing to merge.
|
|
60
|
+
Helpers are only created when work can really run in parallel; one helper is the default.
|
|
61
61
|
- **Agents talk to each other.** Messages go straight from agent to agent through Symbio, and
|
|
62
62
|
delivering a message wakes the recipient. No copy-pasting between terminals.
|
|
63
63
|
- **Shared assets with a gatekeeper.** Anything several agents could change (a file pattern, a
|
|
64
64
|
database, a spreadsheet) can be declared a *shared asset*. Agents request it from core, wait for
|
|
65
65
|
the grant and release it when done. Changes to an asset an agent wasn't granted are flagged at
|
|
66
66
|
merge.
|
|
67
|
+
- **Documentation you can find your way in.** core keeps the project's docs up to date, and the
|
|
68
|
+
Docs tab shows them as a folder tree with search across every doc, links between docs that open
|
|
69
|
+
in place, an outline of long docs, and plain-text sources (e-mails, transcripts) with line
|
|
70
|
+
numbers.
|
|
71
|
+
- **A rules register (optional).** Agents record the project's rules (requirements, decisions,
|
|
72
|
+
constraints) with where each came from, a literal excerpt, who imposes it, how confirmed it is
|
|
73
|
+
and whether it's met. The Docs tab shows them as an index you can filter and search, with each
|
|
74
|
+
citation opening its source at the cited line.
|
|
67
75
|
- **A live plan.** The Plan tab is an animated kanban that moves by itself as helpers pick up
|
|
68
76
|
tasks, ask for review and get merged.
|
|
69
77
|
- **You stay in control.** Questions, plan approvals, permission requests (like installing
|
|
70
78
|
software) and deploy approvals collect in a **Needs you** inbox. Pause one team or everything at
|
|
71
79
|
once.
|
|
72
|
-
- **Connections without exposing secrets.** Give agents a GitHub account
|
|
73
|
-
ask Symbio to push, call the API or deploy, and Symbio does it with credentials the agents never
|
|
80
|
+
- **Connections without exposing secrets.** Give agents a GitHub account, an Azure DevOps
|
|
81
|
+
organization or an SSH server. They ask Symbio to push, open pull requests, call the API or deploy, and Symbio does it with credentials the agents never
|
|
74
82
|
see. Each connection can require your approval for every action.
|
|
75
83
|
- **Resilient.** A supervisor restarts OpenCode if it crashes, a watchdog unsticks hung agents, and
|
|
76
84
|
when a model fails (quota, outage) the agent switches to the next allowed model and carries on.
|
|
77
85
|
- **Safety rails.** Agents can't kill processes by name (which would take down your terminals and
|
|
78
86
|
editors), can't read your SSH keys or Symbio's secret store, and must ask before installing
|
|
79
|
-
software.
|
|
87
|
+
software. They look at images through scaled-down copies, so a few large pictures can't make a
|
|
88
|
+
conversation too big to send.
|
|
80
89
|
|
|
81
90
|
## Requirements
|
|
82
91
|
|
|
@@ -133,7 +142,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
|
|
|
133
142
|
- **Folder:** browse for or paste a folder. An empty folder starts a new project; an existing
|
|
134
143
|
one is studied first. Symbio offers to set up git if the folder doesn't have it.
|
|
135
144
|
- **Setup:** choose where the project documentation goes (in the project, or private), and which
|
|
136
|
-
connections (GitHub, servers) the agents may use.
|
|
145
|
+
connections (GitHub, Azure DevOps, servers) the agents may use.
|
|
137
146
|
- **Models:** pick the models agents may use, and the one core runs on (the crown).
|
|
138
147
|
3. **Talk to core.** Describe what you want. core asks questions, proposes a plan, and waits for
|
|
139
148
|
your approval in the **Plan** tab (it shows a *Review* badge).
|
|
@@ -150,7 +159,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
|
|
|
150
159
|
| **core** | The project's lead agent. It plans, delegates, reviews and merges; it coordinates rather than doing the work itself. |
|
|
151
160
|
| **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`) or, if the project is set to, in the project folder itself. |
|
|
152
161
|
| **Shared asset** | Something more than one agent could change. Access goes through core: request, grant, release. |
|
|
153
|
-
| **Connection** | A GitHub account or SSH server agents can use through Symbio, without ever seeing its credentials. |
|
|
162
|
+
| **Connection** | A GitHub account, Azure DevOps organization or SSH server agents can use through Symbio, without ever seeing its credentials. |
|
|
154
163
|
| **Allowed models** | The models a project's agents may run on. If one fails, agents fall back to the next. |
|
|
155
164
|
|
|
156
165
|
## Where your data lives
|
package/dist/server/api.js
CHANGED
|
@@ -9,7 +9,8 @@ import { describeInspection, inspectFolder } from "./inspect.js";
|
|
|
9
9
|
import { gitAvailable, initRepository } from "./git.js";
|
|
10
10
|
import { pickFolder } from "./folder-picker.js";
|
|
11
11
|
import { beginOauth, cancelOauth, completeOauth, connectWithKey, listProviders, oauthStatus, removeLogin } from "./providers.js";
|
|
12
|
-
import { docsRoot, listDocs, readDoc, writeDoc } from "./docs.js";
|
|
12
|
+
import { docsRoot, listDocs, rawDoc, readDoc, searchDocs, writeDoc } from "./docs.js";
|
|
13
|
+
import { readRegister } from "./rules.js";
|
|
13
14
|
import { PriceBook, projectUsage, resolvePrices } from "./costs.js";
|
|
14
15
|
import { globalDir } from "./store.js";
|
|
15
16
|
// Debug routes pass OpenCode responses through, which can contain provider keys.
|
|
@@ -269,6 +270,7 @@ export function createApi(store, runtime, team, connections) {
|
|
|
269
270
|
allowedModels: allowed,
|
|
270
271
|
coordinatorModel: body.coordinatorModel,
|
|
271
272
|
docsLocation: body.docsLocation === "private" ? "private" : "project",
|
|
273
|
+
rulesRegister: body.rulesRegister === true,
|
|
272
274
|
origin: inspection.empty ? "new" : "existing",
|
|
273
275
|
inspectionSummary: describeInspection(inspection),
|
|
274
276
|
connections: cleanLinks(body.connections),
|
|
@@ -291,6 +293,8 @@ export function createApi(store, runtime, team, connections) {
|
|
|
291
293
|
api.patch("/projects/:id", async (c) => {
|
|
292
294
|
const project = withProject(c.req.param("id"));
|
|
293
295
|
const body = await c.req.json();
|
|
296
|
+
if (typeof body.rulesRegister === "boolean")
|
|
297
|
+
await team.setRulesRegister(project, body.rulesRegister);
|
|
294
298
|
if (body.helperWorkspace === "own" || body.helperWorkspace === "shared")
|
|
295
299
|
await team.setHelperWorkspace(project, body.helperWorkspace);
|
|
296
300
|
if (body.name?.trim())
|
|
@@ -360,6 +364,34 @@ export function createApi(store, runtime, team, connections) {
|
|
|
360
364
|
const project = withProject(c.req.param("id"));
|
|
361
365
|
return c.json({ location: project.docsLocation, root: docsRoot(project), files: await listDocs(project) });
|
|
362
366
|
});
|
|
367
|
+
api.get("/projects/:id/rules", async (c) => {
|
|
368
|
+
const project = withProject(c.req.param("id"));
|
|
369
|
+
return c.json({ enabled: !!project.rulesRegister, ...(await readRegister(project)) });
|
|
370
|
+
});
|
|
371
|
+
// Any file in the docs (a PDF cited by a rule, an image), read-only.
|
|
372
|
+
api.get("/projects/:id/docs/raw", async (c) => {
|
|
373
|
+
const project = withProject(c.req.param("id"));
|
|
374
|
+
try {
|
|
375
|
+
const file = await rawDoc(project, c.req.query("path") ?? "");
|
|
376
|
+
return new Response(new Uint8Array(file.data), {
|
|
377
|
+
headers: {
|
|
378
|
+
"content-type": file.type,
|
|
379
|
+
"content-disposition": `${file.inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
380
|
+
"x-content-type-options": "nosniff",
|
|
381
|
+
"content-security-policy": "default-src 'none'; img-src 'self' blob: data:; style-src 'unsafe-inline'",
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (err.code === "ENOENT")
|
|
387
|
+
throw new HttpError(404, "file not found");
|
|
388
|
+
throw new HttpError(400, err.message);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
api.get("/projects/:id/docs/search", async (c) => {
|
|
392
|
+
const project = withProject(c.req.param("id"));
|
|
393
|
+
return c.json(await searchDocs(project, (c.req.query("q") ?? "").slice(0, 200)));
|
|
394
|
+
});
|
|
363
395
|
api.get("/projects/:id/docs/file", async (c) => {
|
|
364
396
|
const project = withProject(c.req.param("id"));
|
|
365
397
|
const rel = c.req.query("path") ?? "";
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
// Global connections (set once in Settings, used by any project): GitHub accounts
|
|
2
|
-
// and servers reachable over SSH. Public details live in
|
|
3
|
-
// secrets live in the encrypted vault and are never
|
|
1
|
+
// Global connections (set once in Settings, used by any project): GitHub accounts,
|
|
2
|
+
// Azure DevOps organizations and servers reachable over SSH. Public details live in
|
|
3
|
+
// ~/.symbio/connections.json; secrets live in the encrypted vault and are never
|
|
4
|
+
// returned by the API.
|
|
4
5
|
import { promises as fs, readFileSync } from "node:fs";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { SecretVault } from "./secrets.js";
|
|
8
9
|
import { sshExec } from "./ssh.js";
|
|
9
10
|
import { globalDir, newId } from "./store.js";
|
|
11
|
+
/** Organization from what people paste: "org", https://dev.azure.com/org/…, https://org.visualstudio.com/…. */
|
|
12
|
+
export function azureOrganization(value) {
|
|
13
|
+
const v = value.trim();
|
|
14
|
+
const m = /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/?#]+)/i.exec(v) ?? /^https?:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com/i.exec(v);
|
|
15
|
+
return decodeURIComponent(m ? m[1] : v.replace(/\/+$/, ""));
|
|
16
|
+
}
|
|
10
17
|
const SECRET_FIELDS = {
|
|
11
18
|
github: ["token"],
|
|
19
|
+
azure: ["token"],
|
|
12
20
|
server: ["privateKey", "passphrase", "password"],
|
|
13
21
|
};
|
|
14
22
|
export class Connections {
|
|
@@ -37,13 +45,15 @@ export class Connections {
|
|
|
37
45
|
return { ...c, secrets };
|
|
38
46
|
}
|
|
39
47
|
async create(input) {
|
|
40
|
-
if (input.type !== "github" && input.type !== "server")
|
|
41
|
-
throw new Error("type must be github or server");
|
|
48
|
+
if (input.type !== "github" && input.type !== "azure" && input.type !== "server")
|
|
49
|
+
throw new Error("type must be github, azure or server");
|
|
42
50
|
const now = Date.now();
|
|
43
51
|
const base = { id: newId("con"), name: "", createdAt: now, updatedAt: now, lastTest: null };
|
|
44
52
|
const conn = input.type === "github"
|
|
45
53
|
? { ...base, type: "github", account: null }
|
|
46
|
-
:
|
|
54
|
+
: input.type === "azure"
|
|
55
|
+
? { ...base, type: "azure", organization: "", account: null }
|
|
56
|
+
: { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
|
|
47
57
|
this.apply(conn, input);
|
|
48
58
|
if (!conn.name)
|
|
49
59
|
throw new Error("Give the connection a name.");
|
|
@@ -91,6 +101,14 @@ export class Connections {
|
|
|
91
101
|
conn.account = user.login;
|
|
92
102
|
result = { ok: true, message: `Signed in as ${user.login}${user.name ? ` (${user.name})` : ""}`, time: Date.now() };
|
|
93
103
|
}
|
|
104
|
+
else if (conn.type === "azure") {
|
|
105
|
+
const data = await this.azureRequest(conn, "GET", "_apis/connectionData", undefined, "7.1-preview");
|
|
106
|
+
const user = data?.authenticatedUser;
|
|
107
|
+
conn.account = user?.providerDisplayName ?? user?.customDisplayName ?? null;
|
|
108
|
+
const projects = await this.azureRequest(conn, "GET", "_apis/projects").catch(() => null);
|
|
109
|
+
const count = Array.isArray(projects?.value) ? ` — ${projects.value.length} project(s) visible` : "";
|
|
110
|
+
result = { ok: true, message: `Signed in to ${conn.organization}${conn.account ? ` as ${conn.account}` : ""}${count}`, time: Date.now() };
|
|
111
|
+
}
|
|
94
112
|
else {
|
|
95
113
|
const res = await sshExec(await this.sshTarget(conn), "uname -srm 2>/dev/null || ver", 30_000);
|
|
96
114
|
const out = (res.stdout || res.stderr).trim().split("\n")[0];
|
|
@@ -129,6 +147,40 @@ export class Connections {
|
|
|
129
147
|
}
|
|
130
148
|
return target;
|
|
131
149
|
}
|
|
150
|
+
/** Calls the Azure DevOps REST API of the connection's organization with its token. */
|
|
151
|
+
async azureRequest(conn, method, apiPath, body, apiVersion = "7.1") {
|
|
152
|
+
const rel = apiPath.trim().replace(/^\/+/, "");
|
|
153
|
+
if (/^[a-z]+:/i.test(rel))
|
|
154
|
+
throw new Error("Give a path inside the organization (e.g. MyProject/_apis/git/repositories), not a full URL.");
|
|
155
|
+
const url = new URL(`https://dev.azure.com/${encodeURIComponent(conn.organization)}/${rel}`);
|
|
156
|
+
if (!url.searchParams.has("api-version"))
|
|
157
|
+
url.searchParams.set("api-version", apiVersion);
|
|
158
|
+
const token = await this.githubToken(conn);
|
|
159
|
+
const res = await fetch(url, {
|
|
160
|
+
method,
|
|
161
|
+
redirect: "manual", // a sign-in redirect means the token was refused
|
|
162
|
+
headers: {
|
|
163
|
+
authorization: `Basic ${Buffer.from(`:${token}`).toString("base64")}`,
|
|
164
|
+
accept: "application/json",
|
|
165
|
+
"user-agent": "symbio",
|
|
166
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
167
|
+
},
|
|
168
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
169
|
+
});
|
|
170
|
+
const text = await res.text();
|
|
171
|
+
// A rejected token gets a sign-in page (203 or a redirect) rather than a 401.
|
|
172
|
+
const html = /^\s*</.test(text) || (res.headers.get("content-type") ?? "").includes("text/html");
|
|
173
|
+
if (res.status === 401 || res.status === 203 || (res.status >= 300 && res.status < 400) || (html && res.ok)) {
|
|
174
|
+
throw new Error(`Azure DevOps rejected the token for "${conn.organization}" (expired, revoked, or without access to this organization?)`);
|
|
175
|
+
}
|
|
176
|
+
const data = text ? safeJson(text) : null;
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const msg = data?.message ?? res.statusText;
|
|
179
|
+
throw new Error(`Azure DevOps ${res.status}: ${msg}`);
|
|
180
|
+
}
|
|
181
|
+
return data;
|
|
182
|
+
}
|
|
183
|
+
/** The stored token of a GitHub or Azure DevOps connection. */
|
|
132
184
|
async githubToken(conn) {
|
|
133
185
|
const token = await this.vault.get(`${conn.id}:token`);
|
|
134
186
|
if (!token)
|
|
@@ -159,6 +211,8 @@ export class Connections {
|
|
|
159
211
|
apply(conn, input) {
|
|
160
212
|
if (input.name !== undefined)
|
|
161
213
|
conn.name = input.name.trim();
|
|
214
|
+
if (conn.type === "azure" && input.organization !== undefined)
|
|
215
|
+
conn.organization = azureOrganization(input.organization);
|
|
162
216
|
if (conn.type === "server") {
|
|
163
217
|
if (input.host !== undefined)
|
|
164
218
|
conn.host = input.host.trim();
|
|
@@ -173,6 +227,9 @@ export class Connections {
|
|
|
173
227
|
}
|
|
174
228
|
}
|
|
175
229
|
validate(conn) {
|
|
230
|
+
if (conn.type === "azure" && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(conn.organization)) {
|
|
231
|
+
throw new Error("Give the organization, e.g. \"contoso\" or https://dev.azure.com/contoso.");
|
|
232
|
+
}
|
|
176
233
|
if (conn.type === "server") {
|
|
177
234
|
if (!conn.host)
|
|
178
235
|
throw new Error("Host is required.");
|
|
@@ -189,7 +246,7 @@ export class Connections {
|
|
|
189
246
|
if (typeof value === "string" && value.trim())
|
|
190
247
|
await this.vault.set(`${conn.id}:${field}`, value.trim());
|
|
191
248
|
}
|
|
192
|
-
if (conn.type === "github" && !(await this.vault.has(`${conn.id}:token`)))
|
|
249
|
+
if ((conn.type === "github" || conn.type === "azure") && !(await this.vault.has(`${conn.id}:token`)))
|
|
193
250
|
throw new Error("The token is required.");
|
|
194
251
|
if (conn.type === "server") {
|
|
195
252
|
if (conn.auth === "password" && !(await this.vault.has(`${conn.id}:password`)))
|
package/dist/server/docs.js
CHANGED
|
@@ -44,13 +44,60 @@ export async function listDocs(project) {
|
|
|
44
44
|
export async function readDoc(project, rel) {
|
|
45
45
|
return fs.readFile(resolveDoc(project, rel), "utf8");
|
|
46
46
|
}
|
|
47
|
+
const MAX_MATCHES = 300;
|
|
48
|
+
const MAX_SEARCH_BYTES = 2_000_000; // per file; larger files are skipped
|
|
49
|
+
/** Case- and accent-insensitive, so "regra" also finds "Regra" and "regrá". */
|
|
50
|
+
const fold = (s) => s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
51
|
+
/** Lines containing every word of the query, across all docs. */
|
|
52
|
+
export async function searchDocs(project, query) {
|
|
53
|
+
const words = fold(query).split(/\s+/).filter(Boolean);
|
|
54
|
+
if (!words.length)
|
|
55
|
+
return { matches: [], truncated: false };
|
|
56
|
+
const matches = [];
|
|
57
|
+
for (const file of await listDocs(project)) {
|
|
58
|
+
if (file.size > MAX_SEARCH_BYTES)
|
|
59
|
+
continue;
|
|
60
|
+
const lines = (await readDoc(project, file.path).catch(() => "")).split(/\r?\n/);
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const hay = fold(lines[i]);
|
|
63
|
+
if (!words.every((w) => hay.includes(w)))
|
|
64
|
+
continue;
|
|
65
|
+
matches.push({ path: file.path, line: i + 1, text: lines[i].trim().slice(0, 300) });
|
|
66
|
+
if (matches.length >= MAX_MATCHES)
|
|
67
|
+
return { matches, truncated: true };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { matches, truncated: false };
|
|
71
|
+
}
|
|
47
72
|
export async function writeDoc(project, rel, content) {
|
|
48
73
|
const abs = resolveDoc(project, rel);
|
|
49
74
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
50
75
|
await fs.writeFile(abs, content);
|
|
51
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Any file in the docs locations, read-only, with how to send it: sources cited by the rules
|
|
79
|
+
* register are often PDFs or images. Shown inline only for types a browser displays safely
|
|
80
|
+
* (no HTML or SVG, which could run scripts); everything else is a download.
|
|
81
|
+
*/
|
|
82
|
+
export async function rawDoc(project, rel) {
|
|
83
|
+
const abs = resolveDoc(project, rel, true);
|
|
84
|
+
const ext = path.extname(abs).toLowerCase();
|
|
85
|
+
const inline = RAW_INLINE[ext];
|
|
86
|
+
return { data: await fs.readFile(abs), type: inline ?? "application/octet-stream", inline: !!inline, name: path.basename(abs) };
|
|
87
|
+
}
|
|
88
|
+
const RAW_INLINE = {
|
|
89
|
+
".pdf": "application/pdf",
|
|
90
|
+
".png": "image/png",
|
|
91
|
+
".jpg": "image/jpeg",
|
|
92
|
+
".jpeg": "image/jpeg",
|
|
93
|
+
".gif": "image/gif",
|
|
94
|
+
".webp": "image/webp",
|
|
95
|
+
".txt": "text/plain; charset=utf-8",
|
|
96
|
+
".md": "text/plain; charset=utf-8",
|
|
97
|
+
".csv": "text/plain; charset=utf-8",
|
|
98
|
+
};
|
|
52
99
|
/** Validates a doc path against the allowed locations. Throws on anything else. */
|
|
53
|
-
function resolveDoc(project, rel) {
|
|
100
|
+
function resolveDoc(project, rel, anyType = false) {
|
|
54
101
|
const root = docsRoot(project);
|
|
55
102
|
const normalized = rel.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
56
103
|
const abs = path.resolve(root, normalized);
|
|
@@ -59,7 +106,7 @@ function resolveDoc(project, rel) {
|
|
|
59
106
|
const allowed = project.docsLocation === "private"
|
|
60
107
|
? insideRoot
|
|
61
108
|
: insideRoot && (relBack === "AGENTS.md" || relBack.startsWith("docs/"));
|
|
62
|
-
if (!allowed || !TEXT_EXT.has(path.extname(abs).toLowerCase())) {
|
|
109
|
+
if (!allowed || (!anyType && !TEXT_EXT.has(path.extname(abs).toLowerCase()))) {
|
|
63
110
|
throw new Error(`Not a docs file: ${rel}`);
|
|
64
111
|
}
|
|
65
112
|
// A symlink inside the docs could point at any file (an SSH key, say); compare real
|
package/dist/server/guard.js
CHANGED
|
@@ -99,16 +99,37 @@ const SECRET_SHELL = [
|
|
|
99
99
|
"*.symbio*backups*",
|
|
100
100
|
"*opencode*auth.json*",
|
|
101
101
|
];
|
|
102
|
+
// Images read with the read tool go into the conversation at full size and are re-sent every
|
|
103
|
+
// turn; a few large ones made a conversation too big to send at all. Agents look at images
|
|
104
|
+
// through Symbio's view_image tool instead, which scales them down. These reads are "ask", not
|
|
105
|
+
// "deny", only so the refusal can carry a message: Symbio rejects them itself, at once, telling
|
|
106
|
+
// the agent to use view_image (a bare deny just says "Permission denied", and a model gave up).
|
|
107
|
+
// Patterns are case-sensitive, hence both spellings.
|
|
108
|
+
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
|
109
|
+
const IMAGE_PATHS = IMAGE_EXTENSIONS.flatMap((ext) => [`*.${ext}`, `*.${ext.toUpperCase()}`]);
|
|
110
|
+
/** A read Symbio refuses on the agent's behalf (an image file), with what to do instead. */
|
|
111
|
+
export function imageReadRefusal(action, resources) {
|
|
112
|
+
if (action !== "read" || !resources.length || !resources.every((r) => IMAGE_PATHS.some((p) => wildcardMatch(p, r))))
|
|
113
|
+
return null;
|
|
114
|
+
return ("Symbio blocks reading image files directly: a full-size image stays in your conversation and is sent again on every " +
|
|
115
|
+
`turn. Use Symbio's view_image tool instead (in code mode: tools.symbio.view_image({ path: ${JSON.stringify(resources[0])} })); ` +
|
|
116
|
+
"it returns a scaled-down copy, and a crop shows details at full resolution.");
|
|
117
|
+
}
|
|
102
118
|
const rules = (action, effect, patterns) => patterns.map((resource) => ({ action, resource, effect }));
|
|
103
119
|
// Any matching deny blocks a call; otherwise any ask prompts. Deny rules come last
|
|
104
120
|
// so they also win under "last match wins".
|
|
105
121
|
export const AGENT_PERMISSIONS = [
|
|
106
122
|
...rules("shell", "ask", INSTALL_PATTERNS),
|
|
123
|
+
...rules("read", "ask", IMAGE_PATHS), // refused by Symbio with a message (imageReadRefusal)
|
|
107
124
|
...rules("shell", "deny", KILL_PATTERNS),
|
|
108
125
|
...rules("shell", "deny", SECRET_SHELL),
|
|
109
126
|
...rules("read", "deny", SECRET_PATHS),
|
|
110
127
|
...rules("edit", "deny", SECRET_PATHS),
|
|
111
128
|
];
|
|
129
|
+
/** Whether a path is one agents may never read (keys, Symbio's secrets…), for Symbio's own file tools. */
|
|
130
|
+
export function isSecretPath(file) {
|
|
131
|
+
return SECRET_PATHS.some((p) => wildcardMatch(p, file));
|
|
132
|
+
}
|
|
112
133
|
/** Text for agent briefs explaining the rule and the alternative. */
|
|
113
134
|
export const PROCESS_RULES = `- Never kill processes by name or PID (taskkill, Stop-Process, kill, pkill…):
|
|
114
135
|
other programs on this machine — the human's terminals and editors, other
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// view_image: agents look at images through scaled-down copies. A full-size image read into a
|
|
2
|
+
// conversation stays there and is re-sent every turn; a few large ones made a conversation too
|
|
3
|
+
// big to send at all. A crop shows one region at full resolution when details matter.
|
|
4
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { IMAGE_EXTENSIONS, isSecretPath } from "./guard.js";
|
|
8
|
+
export const DEFAULT_IMAGE_SIZE = 1024;
|
|
9
|
+
const MAX_IMAGE_SIZE = 2048;
|
|
10
|
+
const MAX_BYTES = 1_500_000; // what we send back, after scaling
|
|
11
|
+
export async function viewImage(folder, input) {
|
|
12
|
+
const given = String(input.path ?? "").trim();
|
|
13
|
+
if (!given)
|
|
14
|
+
throw new Error("Give the image's path.");
|
|
15
|
+
const file = path.resolve(folder, given);
|
|
16
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
17
|
+
if (!IMAGE_EXTENSIONS.includes(ext))
|
|
18
|
+
throw new Error(`Not an image (${IMAGE_EXTENSIONS.join(", ")}): ${given}`);
|
|
19
|
+
if (isSecretPath(file))
|
|
20
|
+
throw new Error(`Not allowed: ${given}`);
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
throw new Error(`No such file: ${file}`);
|
|
23
|
+
const size = Math.min(MAX_IMAGE_SIZE, Math.max(64, Math.round(Number(input.max_size) || DEFAULT_IMAGE_SIZE)));
|
|
24
|
+
let image = sharp(await fs.readFile(file), { animated: false }).rotate(); // apply EXIF orientation
|
|
25
|
+
const meta = await image.metadata();
|
|
26
|
+
const width = meta.autoOrient?.width ?? meta.width ?? 0;
|
|
27
|
+
const height = meta.autoOrient?.height ?? meta.height ?? 0;
|
|
28
|
+
if (!width || !height)
|
|
29
|
+
throw new Error(`Couldn't read the image: ${given}`);
|
|
30
|
+
let note = `${given}: ${width}×${height} px`;
|
|
31
|
+
const crop = input.crop;
|
|
32
|
+
if (crop && typeof crop === "object") {
|
|
33
|
+
const left = Math.max(0, Math.floor(Number(crop.left) || 0));
|
|
34
|
+
const top = Math.max(0, Math.floor(Number(crop.top) || 0));
|
|
35
|
+
const w = Math.min(width - left, Math.floor(Number(crop.width) || 0));
|
|
36
|
+
const h = Math.min(height - top, Math.floor(Number(crop.height) || 0));
|
|
37
|
+
if (w <= 0 || h <= 0)
|
|
38
|
+
throw new Error(`The crop must lie inside the ${width}×${height} image (left, top, width, height in pixels).`);
|
|
39
|
+
image = image.extract({ left, top, width: w, height: h });
|
|
40
|
+
note += `, region ${w}×${h} at (${left}, ${top})`;
|
|
41
|
+
}
|
|
42
|
+
image = image.resize({ width: size, height: size, fit: "inside", withoutEnlargement: true });
|
|
43
|
+
// JPEG keeps it small; flatten transparency onto white so it stays readable.
|
|
44
|
+
let quality = 82;
|
|
45
|
+
let out = await image.clone().flatten({ background: "#ffffff" }).jpeg({ quality }).toBuffer({ resolveWithObject: true });
|
|
46
|
+
while (out.data.length > MAX_BYTES && quality > 40) {
|
|
47
|
+
quality -= 15;
|
|
48
|
+
out = await image.clone().flatten({ background: "#ffffff" }).jpeg({ quality }).toBuffer({ resolveWithObject: true });
|
|
49
|
+
}
|
|
50
|
+
note += `, shown at ${out.info.width}×${out.info.height}.`;
|
|
51
|
+
if (!crop && (out.info.width < width || out.info.height < height))
|
|
52
|
+
note += " For details, ask for a crop (a region in the original's pixels), shown at full resolution.";
|
|
53
|
+
return { content: [{ type: "text", text: note }, { type: "image", data: out.data.toString("base64"), mimeType: "image/jpeg" }] };
|
|
54
|
+
}
|
package/dist/server/mcp.js
CHANGED
|
@@ -58,8 +58,8 @@ async function handleOne(req, token, resolve) {
|
|
|
58
58
|
if (caller.ctx == null)
|
|
59
59
|
return fail(-32001, "could not identify the calling agent");
|
|
60
60
|
try {
|
|
61
|
-
const
|
|
62
|
-
return reply({ content: [{ type: "text", text }] });
|
|
61
|
+
const result = await tool.run(req.params?.arguments ?? {}, caller.ctx);
|
|
62
|
+
return reply({ content: typeof result === "string" ? [{ type: "text", text: result }] : result.content });
|
|
63
63
|
}
|
|
64
64
|
catch (err) {
|
|
65
65
|
// Tool errors go back to the model as a result, so it can react.
|
package/dist/server/prompts.js
CHANGED
|
@@ -4,6 +4,13 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { docsPaths } from "./docs.js";
|
|
6
6
|
import { PROCESS_RULES } from "./guard.js";
|
|
7
|
+
// Seen live: a coordinator read eight 2–4 MB images, its conversation reached 25 MB and the
|
|
8
|
+
// provider gave up receiving it ("Timed out reading request body"), whatever the model.
|
|
9
|
+
const KEEP_CONVERSATION_SMALL = `- Keep your conversation small: whatever you read stays in it and is sent to
|
|
10
|
+
the model again on every turn, until it becomes too large to send. Look at
|
|
11
|
+
images with Symbio's \`view_image\` tool (a scaled-down copy; ask for a crop to
|
|
12
|
+
see details) — reading image files directly is blocked. For a big file, read
|
|
13
|
+
the part you need. Reviewing many images is work for a helper.`;
|
|
7
14
|
// OpenCode's code mode posts "tool catalog changed" notices that list only its own tools and
|
|
8
15
|
// say the catalog is complete; Symbio's tools still work (seen live: a coordinator kept using
|
|
9
16
|
// them after such a notice, another gave up without trying).
|
|
@@ -69,6 +76,8 @@ ${onboarding(project)}
|
|
|
69
76
|
|
|
70
77
|
${docsSection(project)}
|
|
71
78
|
|
|
79
|
+
${rulesSection(project, "coordinator")}
|
|
80
|
+
|
|
72
81
|
## Your team tools (from the "symbio" tool server)
|
|
73
82
|
|
|
74
83
|
- \`submit_plan\`, \`add_task\`, \`list_tasks\` — the plan. Tasks are for helpers;
|
|
@@ -133,6 +142,7 @@ ${connectionsSection(connections)}
|
|
|
133
142
|
- Never start long-running processes (servers, watchers, anything that doesn't
|
|
134
143
|
exit on its own) in the foreground.
|
|
135
144
|
${PROCESS_RULES}
|
|
145
|
+
${KEEP_CONVERSATION_SMALL}
|
|
136
146
|
`;
|
|
137
147
|
}
|
|
138
148
|
/** Where helpers work, for the coordinator's tool list. */
|
|
@@ -176,6 +186,43 @@ The folder already has content. Before planning anything:
|
|
|
176
186
|
|
|
177
187
|
Respect what is there: follow existing conventions and guidance files.`;
|
|
178
188
|
}
|
|
189
|
+
/** The rules register, when the project keeps one. */
|
|
190
|
+
function rulesSection(project, role) {
|
|
191
|
+
if (!project.rulesRegister)
|
|
192
|
+
return "";
|
|
193
|
+
const common = `## The rules register
|
|
194
|
+
|
|
195
|
+
This project keeps a register of its rules: anything the work must respect (a
|
|
196
|
+
requirement, a decision, a constraint, a style rule), each with where it came
|
|
197
|
+
from and whether it's met. The human reads it in Symbio's Docs tab. Use the
|
|
198
|
+
tools \`list_rules\`, \`save_rule\`, \`save_rule_area\` and \`save_rule_source\`; never
|
|
199
|
+
edit the register file directly.
|
|
200
|
+
|
|
201
|
+
- **Only what the sources say.** A rule's text states what its source says.
|
|
202
|
+
Cite the source with its location and a literal excerpt. If nothing backs a
|
|
203
|
+
rule yet, record it without citations (it shows as unsourced) — never invent a
|
|
204
|
+
source, date or person.
|
|
205
|
+
- **Confirmation:** \`found\` (extracted, nobody checked), \`checked-by-agent\`
|
|
206
|
+
(you compared text, location and excerpt against the source), \`checked-by-human\`
|
|
207
|
+
(only when the human says so), \`contested\` (a source contradicts it; ask).
|
|
208
|
+
- **Status:** whether the work meets it today: \`done\`, \`partial\`, \`missing\`,
|
|
209
|
+
\`not-applicable\`. When the work differs from the rule, say so in \`current\`
|
|
210
|
+
(start with "Diverges:").
|
|
211
|
+
- **Stable ids.** A rule keeps its id forever; a rule that changes is superseded
|
|
212
|
+
by a new one (\`superseded_by\`), and the old one stays for the record.
|
|
213
|
+
- **Rules first.** A change of behavior that comes from a new decision is recorded
|
|
214
|
+
as a rule (with that decision as its source) before the work changes.`;
|
|
215
|
+
return role === "coordinator"
|
|
216
|
+
? `${common}
|
|
217
|
+
- **Keep it current without stopping the work.** Record rules as they come up:
|
|
218
|
+
in the human's answers, documents and meetings, and in what helpers find.
|
|
219
|
+
Ask helpers to update the rules their task touches (status, current state).
|
|
220
|
+
Areas group rules by part of the project, with stages in order; sources get
|
|
221
|
+
short codes and their file, so the human can open the cited place.`
|
|
222
|
+
: `${common}
|
|
223
|
+
- When your task touches a rule, update its \`status\` and \`current\` once you're
|
|
224
|
+
done. A rule you discover goes in as \`found\`; tell the coordinator.`;
|
|
225
|
+
}
|
|
179
226
|
function docsSection(project) {
|
|
180
227
|
const { entry, folder } = docsPaths(project);
|
|
181
228
|
if (project.docsLocation === "private") {
|
|
@@ -248,6 +295,8 @@ documents, data, configuration, external systems). Before changing one:
|
|
|
248
295
|
|
|
249
296
|
If you are unsure whether something is shared, ask the coordinator.
|
|
250
297
|
|
|
298
|
+
${rulesSection(project, "worker")}
|
|
299
|
+
|
|
251
300
|
## Tools (from the "symbio" tool server)
|
|
252
301
|
|
|
253
302
|
\`send_message\`, \`ask_human\`, \`list_team\`, \`list_assets\`, \`request_asset\`,
|
|
@@ -278,6 +327,7 @@ ${connectionsSection(connections)}
|
|
|
278
327
|
- Never start long-running processes (servers, watchers, anything that doesn't
|
|
279
328
|
exit on its own) in the foreground.
|
|
280
329
|
${PROCESS_RULES}
|
|
330
|
+
${KEEP_CONVERSATION_SMALL}
|
|
281
331
|
`;
|
|
282
332
|
}
|
|
283
333
|
/** First turn, sent by Symbio right after the coordinator is created, so it speaks first. */
|
|
@@ -287,15 +337,33 @@ export function kickoffMessage(project) {
|
|
|
287
337
|
: "Symbio: the project was just created on a folder that already has content (see the folder summary in your instructions). Greet the human briefly, mention in one or two sentences what you can see at a glance, and offer to study the folder before planning.";
|
|
288
338
|
}
|
|
289
339
|
function connectionsSection(connections) {
|
|
290
|
-
return `## Connections (GitHub, servers)
|
|
340
|
+
return `## Connections (GitHub, Azure DevOps, servers)
|
|
291
341
|
|
|
292
342
|
${connections || "None."}
|
|
293
343
|
|
|
294
|
-
Use connections only through Symbio's tools: \`list_connections
|
|
295
|
-
\`git_pull
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
344
|
+
Use connections only through Symbio's tools: \`list_connections\`; \`git_push\`,
|
|
345
|
+
\`git_pull\` (GitHub or Azure DevOps); \`github_api\` (GitHub); \`azure_devops_api\`
|
|
346
|
+
(Azure DevOps: pull requests, repositories, branches); \`remote_exec\`,
|
|
347
|
+
\`remote_upload\` (servers). Symbio performs the action with the stored
|
|
348
|
+
credentials — you never see them. To open a pull request: push the branch with
|
|
349
|
+
\`git_push\`, then create it with \`github_api\` or \`azure_devops_api\`.
|
|
350
|
+
Remote commands must be non-interactive. Always give a clear \`reason\`: when a
|
|
351
|
+
connection needs approval, the human decides based on it, and the result
|
|
352
|
+
arrives later as a message.
|
|
353
|
+
|
|
354
|
+
## Credentials
|
|
355
|
+
|
|
356
|
+
- **Symbio's secrets are off-limits:** its vault and connections, SSH keys, its
|
|
357
|
+
UI key. Use connections only through the tools above; never try to read them.
|
|
358
|
+
- **The project's own credentials** (in its config files: connection strings,
|
|
359
|
+
\`.env\`, \`Web.config\`, \`appsettings.json\`…) and **credentials the human gives
|
|
360
|
+
you** may be used for the task, e.g. connecting to a database the work needs.
|
|
361
|
+
A developer on this project would use them too; don't refuse a task over that.
|
|
362
|
+
- **Don't spread them:** never put a credential into anything that is committed
|
|
363
|
+
or shared — new files in the project, docs, the rules register, the plan,
|
|
364
|
+
messages to other agents — and don't print it in your answers. Prefer
|
|
365
|
+
commands that read it from the config file over pasting it into a command line.
|
|
366
|
+
- **Respect the environment:** if the human says a system is a read-only mirror
|
|
367
|
+
or production, only read from it; changing production needs the human's
|
|
368
|
+
explicit OK.`;
|
|
301
369
|
}
|