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 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. Helpers are only created when work can really run in
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 or an SSH server. They
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
@@ -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 ~/.symbio/connections.json;
3
- // secrets live in the encrypted vault and are never returned by the API.
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
- : { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
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`)))
@@ -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
@@ -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
+ }
@@ -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 text = await tool.run(req.params?.arguments ?? {}, caller.ctx);
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.
@@ -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\`, \`git_push\`,
295
- \`git_pull\`, \`github_api\` (GitHub) and \`remote_exec\`, \`remote_upload\` (servers).
296
- Symbio performs the action with the stored credentials — you never see them.
297
- Never ask for credentials, never write secrets into files or commands, and never
298
- look for them on this machine. Remote commands must be non-interactive. Always
299
- give a clear \`reason\`: when a connection needs approval, the human decides based
300
- on it, and the result arrives later as a message.`;
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
  }