symbio 0.3.0 → 0.5.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 +35 -0
- package/README.md +14 -4
- package/dist/server/api.js +47 -1
- package/dist/server/docs.js +49 -2
- package/dist/server/guard.js +21 -0
- package/dist/server/images.js +54 -0
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +2 -2
- package/dist/server/opencode/client.js +1 -0
- package/dist/server/prompts.js +93 -7
- package/dist/server/rules.js +191 -0
- package/dist/server/store.js +5 -0
- package/dist/server/team.js +240 -42
- package/dist/server/tools.js +126 -7
- package/dist/server/updates.js +34 -0
- package/dist/server/workspaces.js +43 -5
- package/dist/web/assets/{index-BM4oACfZ.css → index-CtcmbMmw.css} +1 -1
- package/dist/web/assets/index-yZ8d_EGH.js +53 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CrEYaqhD.js +0 -52
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.5.0]
|
|
9
|
+
|
|
10
|
+
- Docs tab: a folder tree instead of a flat list; search across all docs (any case or accent) that
|
|
11
|
+
opens the doc at the matching line; links and paths in backticks that point to another doc open
|
|
12
|
+
it, with Back; an "On this page" outline for long docs; .txt sources shown with line numbers.
|
|
13
|
+
- Rules register (optional, per project; on by default for new projects): agents record rules with
|
|
14
|
+
their sources, literal excerpts, who imposes them, confirmation and status through new tools, and
|
|
15
|
+
the Docs tab shows them as a filterable index grouped by area and stage. A citation opens its
|
|
16
|
+
source: a text doc at the cited line, a PDF in a new tab at the cited page.
|
|
17
|
+
- Links in docs to PDFs and images open them in a new tab.
|
|
18
|
+
- `view_image`: agents look at images through a scaled-down copy (or a full-resolution crop);
|
|
19
|
+
reading image files directly is refused with a message pointing to it, so images can't bloat a
|
|
20
|
+
conversation.
|
|
21
|
+
- An agent whose conversation becomes too large to send ("Timed out reading request body", often
|
|
22
|
+
after reading full-size images) continues in a fresh session with a handoff note, instead of
|
|
23
|
+
Symbio trying every other model.
|
|
24
|
+
- A helper's folder that was deleted outside Symbio is recreated from its branch (committed work
|
|
25
|
+
kept), and the human and the helper are told.
|
|
26
|
+
- A project reopened from its folder takes the rules register choice made in the New project dialog.
|
|
27
|
+
|
|
28
|
+
## [0.4.0]
|
|
29
|
+
|
|
30
|
+
- Helpers can work in the project folder itself instead of their own copy on a branch: what one
|
|
31
|
+
writes, the next sees at once, with nothing to merge (for pipelines and step-by-step work).
|
|
32
|
+
Settings → Team sets the default for new helpers; core can choose per helper; existing helpers
|
|
33
|
+
can be moved into the project folder from their chat (or by core) once their work is merged.
|
|
34
|
+
- Background processes belong to the agent that started them: in a shared folder, agents only
|
|
35
|
+
see and stop their own, and stopping a helper stops only its processes.
|
|
36
|
+
- Agents are told that OpenCode's "tool catalog changed" notices don't list Symbio's tools, so they
|
|
37
|
+
keep using them instead of reporting them missing.
|
|
38
|
+
- update_task accepts summary as well as note, so review notes aren't lost.
|
|
39
|
+
- Symbio no longer crashes when OpenCode is restarted (to free memory) while it was reading usage;
|
|
40
|
+
errors in background work are logged instead of stopping the app.
|
|
41
|
+
- Long chats: asking for a 200-message page no longer fails.
|
|
42
|
+
|
|
8
43
|
## [0.3.0]
|
|
9
44
|
|
|
10
45
|
- Remote access: `sym remote` (or Settings → Remote access) opens a temporary Cloudflare link with an
|
package/README.md
CHANGED
|
@@ -55,14 +55,23 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
|
|
|
55
55
|
- **One conversation, a whole team.** You chat with core. It asks what you want, studies an
|
|
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
|
-
branch
|
|
59
|
-
|
|
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
|
+
Helpers are only created when work can really run in parallel; one helper is the default.
|
|
60
61
|
- **Agents talk to each other.** Messages go straight from agent to agent through Symbio, and
|
|
61
62
|
delivering a message wakes the recipient. No copy-pasting between terminals.
|
|
62
63
|
- **Shared assets with a gatekeeper.** Anything several agents could change (a file pattern, a
|
|
63
64
|
database, a spreadsheet) can be declared a *shared asset*. Agents request it from core, wait for
|
|
64
65
|
the grant and release it when done. Changes to an asset an agent wasn't granted are flagged at
|
|
65
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.
|
|
66
75
|
- **A live plan.** The Plan tab is an animated kanban that moves by itself as helpers pick up
|
|
67
76
|
tasks, ask for review and get merged.
|
|
68
77
|
- **You stay in control.** Questions, plan approvals, permission requests (like installing
|
|
@@ -75,7 +84,8 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
|
|
|
75
84
|
when a model fails (quota, outage) the agent switches to the next allowed model and carries on.
|
|
76
85
|
- **Safety rails.** Agents can't kill processes by name (which would take down your terminals and
|
|
77
86
|
editors), can't read your SSH keys or Symbio's secret store, and must ask before installing
|
|
78
|
-
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.
|
|
79
89
|
|
|
80
90
|
## Requirements
|
|
81
91
|
|
|
@@ -147,7 +157,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
|
|
|
147
157
|
| Term | Meaning |
|
|
148
158
|
|---|---|
|
|
149
159
|
| **core** | The project's lead agent. It plans, delegates, reviews and merges; it coordinates rather than doing the work itself. |
|
|
150
|
-
| **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`). |
|
|
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. |
|
|
151
161
|
| **Shared asset** | Something more than one agent could change. Access goes through core: request, grant, release. |
|
|
152
162
|
| **Connection** | A GitHub account or SSH server agents can use through Symbio, without ever seeing its credentials. |
|
|
153
163
|
| **Allowed models** | The models a project's agents may run on. If one fails, agents fall back to the next. |
|
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,10 @@ 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);
|
|
298
|
+
if (body.helperWorkspace === "own" || body.helperWorkspace === "shared")
|
|
299
|
+
await team.setHelperWorkspace(project, body.helperWorkspace);
|
|
294
300
|
if (body.name?.trim())
|
|
295
301
|
project.name = body.name.trim();
|
|
296
302
|
if (body.renewSessions === "manual" || body.renewSessions === "after-task")
|
|
@@ -358,6 +364,34 @@ export function createApi(store, runtime, team, connections) {
|
|
|
358
364
|
const project = withProject(c.req.param("id"));
|
|
359
365
|
return c.json({ location: project.docsLocation, root: docsRoot(project), files: await listDocs(project) });
|
|
360
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
|
+
});
|
|
361
395
|
api.get("/projects/:id/docs/file", async (c) => {
|
|
362
396
|
const project = withProject(c.req.param("id"));
|
|
363
397
|
const rel = c.req.query("path") ?? "";
|
|
@@ -501,6 +535,18 @@ export function createApi(store, runtime, team, connections) {
|
|
|
501
535
|
return c.json({ error: err.message }, 400);
|
|
502
536
|
}
|
|
503
537
|
});
|
|
538
|
+
// Moves a helper from its own copy (branch) into the project folder.
|
|
539
|
+
api.post("/projects/:id/agents/:agentId/move-to-project-folder", async (c) => {
|
|
540
|
+
const project = withProject(c.req.param("id"));
|
|
541
|
+
const agent = withAgent(project, c.req.param("agentId"));
|
|
542
|
+
try {
|
|
543
|
+
await team.moveToProjectFolder(project, agent, "moved by the human");
|
|
544
|
+
return c.json(project);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
return c.json({ error: err.message }, 400);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
504
550
|
// A fresh session for a long-running agent (keeps its identity; the old session stays readable).
|
|
505
551
|
api.post("/projects/:id/agents/:agentId/renew", async (c) => {
|
|
506
552
|
const project = withProject(c.req.param("id"));
|
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/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import { OpencodeRuntime } from "./opencode/runtime.js";
|
|
|
16
16
|
import { Store, globalDir } from "./store.js";
|
|
17
17
|
import { Team } from "./team.js";
|
|
18
18
|
import { removeOrphanWorkspaces } from "./workspaces.js";
|
|
19
|
+
// Background work (timers, event handlers) must never take the whole app down: an error there
|
|
20
|
+
// (e.g. OpenCode restarting mid-call) is logged and the next tick tries again.
|
|
21
|
+
process.on("unhandledRejection", (err) => console.error("[unhandled]", err instanceof Error ? err.stack ?? err.message : err));
|
|
19
22
|
const PORT = Number(process.env.SYMBIO_PORT ?? 4310);
|
|
20
23
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
// dist/server/index.js → dist/web ; src/server/index.ts → dist/web (after a build)
|
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.
|
|
@@ -101,6 +101,7 @@ export class OpencodeClient {
|
|
|
101
101
|
* "older than id" (verified against 2.1.4, see docs/opencode-api.md).
|
|
102
102
|
*/
|
|
103
103
|
async messagePage(sessionId, limit, before) {
|
|
104
|
+
limit = Math.min(limit, 199); // OpenCode refuses more than 200, and we ask for one extra
|
|
104
105
|
const params = new URLSearchParams({ limit: String(limit + 1) }); // one extra tells whether more exist
|
|
105
106
|
if (before)
|
|
106
107
|
params.set("cursor", Buffer.from(JSON.stringify({ id: before, order: "desc", direction: "next" })).toString("base64url"));
|
package/dist/server/prompts.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
// Briefings injected into agent sessions as instruction entries.
|
|
2
2
|
// Keep these domain-neutral: projects can be software, writing, research, design, anything.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { docsPaths } from "./docs.js";
|
|
4
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.`;
|
|
14
|
+
// OpenCode's code mode posts "tool catalog changed" notices that list only its own tools and
|
|
15
|
+
// say the catalog is complete; Symbio's tools still work (seen live: a coordinator kept using
|
|
16
|
+
// them after such a notice, another gave up without trying).
|
|
17
|
+
const FINDING_TOOLS = `These tools come from the "symbio" tool server. In OpenCode's code mode you call
|
|
18
|
+
them inside \`execute\` as \`tools.symbio.<name>(…)\`, and \`search({ namespace: "symbio" })\`
|
|
19
|
+
finds them. OpenCode's tool catalog notices may list only its own tools, even
|
|
20
|
+
when they say the catalog is complete: Symbio's tools are still there. Never
|
|
21
|
+
conclude they are gone without calling one; if a call really fails, tell the
|
|
22
|
+
human what the error said.`;
|
|
5
23
|
export function coordinatorBrief(project, connections = "") {
|
|
6
24
|
return `# You are "core", the coordinator of a Symbio project
|
|
7
25
|
|
|
@@ -58,6 +76,8 @@ ${onboarding(project)}
|
|
|
58
76
|
|
|
59
77
|
${docsSection(project)}
|
|
60
78
|
|
|
79
|
+
${rulesSection(project, "coordinator")}
|
|
80
|
+
|
|
61
81
|
## Your team tools (from the "symbio" tool server)
|
|
62
82
|
|
|
63
83
|
- \`submit_plan\`, \`add_task\`, \`list_tasks\` — the plan. Tasks are for helpers;
|
|
@@ -71,17 +91,20 @@ ${docsSection(project)}
|
|
|
71
91
|
marks its tasks in review as done.
|
|
72
92
|
- \`spawn_agent\` — create a helper agent with a name, a purpose, its first task
|
|
73
93
|
(\`task_id\` from the plan, or a description) and optionally a model. ${project.allowedModels.length > 1 ? "Pick cheaper models for simpler work. " : ""}Allowed models: ${project.allowedModels.join(", ")}.
|
|
74
|
-
|
|
75
|
-
it only sees **committed** files, so commit before spawning if it needs recent
|
|
76
|
-
changes.
|
|
94
|
+
${helperFolders(project)}
|
|
77
95
|
- \`send_message\` — message an agent. Delivering a message wakes the recipient.
|
|
78
96
|
- \`list_team\` — who exists, what they do, whether they are working.
|
|
79
|
-
- \`merge_work\` — bring a helper's committed work into the project
|
|
80
|
-
checked against shared-asset grants; ungranted
|
|
97
|
+
- \`merge_work\` — bring a helper's committed work into the project (helpers with
|
|
98
|
+
their own copy). Merges are checked against shared-asset grants; ungranted
|
|
99
|
+
changes are reported to you.
|
|
100
|
+
- \`move_to_project_folder\` — move an idle helper from its own copy into the
|
|
101
|
+
project folder (after its work is merged); it continues in a fresh session.
|
|
81
102
|
- \`stop_agent\` — stop a helper when its area of the project is finished for good.
|
|
82
103
|
- \`declare_asset\`, \`grant_asset\`, \`deny_asset\`, \`list_assets\` — the shared-asset register.
|
|
83
104
|
- \`ask_human\` — reach the human when you are not in a direct conversation.
|
|
84
105
|
|
|
106
|
+
${FINDING_TOOLS}
|
|
107
|
+
|
|
85
108
|
Team members are created with \`spawn_agent\` only. OpenCode's built-in subagent
|
|
86
109
|
tool is fine for quick, read-only lookups, but never use it to do project work:
|
|
87
110
|
Symbio can't see, coordinate or merge what built-in subagents do.
|
|
@@ -119,8 +142,25 @@ ${connectionsSection(connections)}
|
|
|
119
142
|
- Never start long-running processes (servers, watchers, anything that doesn't
|
|
120
143
|
exit on its own) in the foreground.
|
|
121
144
|
${PROCESS_RULES}
|
|
145
|
+
${KEEP_CONVERSATION_SMALL}
|
|
122
146
|
`;
|
|
123
147
|
}
|
|
148
|
+
/** Where helpers work, for the coordinator's tool list. */
|
|
149
|
+
function helperFolders(project) {
|
|
150
|
+
if (!existsSync(path.join(project.directory, ".git"))) {
|
|
151
|
+
return " Helpers work directly in the project folder, alongside you.";
|
|
152
|
+
}
|
|
153
|
+
const def = project.helperWorkspace === "shared" ? "shared" : "own";
|
|
154
|
+
return ` Where a helper works (\`workspace\`; this project's default is **${def}**, set by
|
|
155
|
+
the human):
|
|
156
|
+
- **own**: its own isolated copy on its own branch. It only sees **committed**
|
|
157
|
+
files (commit before spawning if it needs recent changes), and its work
|
|
158
|
+
reaches the project when you merge it. For parallel work on the same files.
|
|
159
|
+
- **shared**: the project folder itself. What it writes is there at once for
|
|
160
|
+
you and the next helper; nothing to merge, and it doesn't commit. For steps
|
|
161
|
+
that produce or check files others use next (pipelines, reviews of outputs).
|
|
162
|
+
Helpers in the project folder claim the files they write as shared assets.`;
|
|
163
|
+
}
|
|
124
164
|
function onboarding(project) {
|
|
125
165
|
if (project.origin === "new") {
|
|
126
166
|
return `## Starting point
|
|
@@ -146,6 +186,43 @@ The folder already has content. Before planning anything:
|
|
|
146
186
|
|
|
147
187
|
Respect what is there: follow existing conventions and guidance files.`;
|
|
148
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
|
+
}
|
|
149
226
|
function docsSection(project) {
|
|
150
227
|
const { entry, folder } = docsPaths(project);
|
|
151
228
|
if (project.docsLocation === "private") {
|
|
@@ -194,7 +271,11 @@ assets. Your tasks come from the coordinator.
|
|
|
194
271
|
? `You work in your own isolated copy of the project, on branch \`${agent.branch}\`.
|
|
195
272
|
Commit your work there with clear messages; never merge or push — the
|
|
196
273
|
coordinator merges your work into the project.`
|
|
197
|
-
:
|
|
274
|
+
: `You work directly in the project folder, alongside the coordinator and other
|
|
275
|
+
agents: what you write is there for them at once, and theirs for you. Before
|
|
276
|
+
writing files others may also write, claim them as a shared asset (below).
|
|
277
|
+
Don't commit, merge or push unless the coordinator asks; don't touch files or
|
|
278
|
+
processes that aren't yours.`}
|
|
198
279
|
- When you finish a task, or are blocked, report it once: with \`update_task\`
|
|
199
280
|
for plan tasks (see below), otherwise with \`send_message\` to the coordinator
|
|
200
281
|
(what you did, where, anything they should know). Then end your turn; new
|
|
@@ -214,12 +295,16 @@ documents, data, configuration, external systems). Before changing one:
|
|
|
214
295
|
|
|
215
296
|
If you are unsure whether something is shared, ask the coordinator.
|
|
216
297
|
|
|
298
|
+
${rulesSection(project, "worker")}
|
|
299
|
+
|
|
217
300
|
## Tools (from the "symbio" tool server)
|
|
218
301
|
|
|
219
302
|
\`send_message\`, \`ask_human\`, \`list_team\`, \`list_assets\`, \`request_asset\`,
|
|
220
303
|
\`release_asset\`, \`check_messages\`, \`list_tasks\`, \`update_task\`.
|
|
221
304
|
|
|
222
|
-
|
|
305
|
+
${FINDING_TOOLS}
|
|
306
|
+
|
|
307
|
+
Tasks from the plan arrive as "Task T3: …". When you finish one, ${isolated ? "commit and " : ""}call
|
|
223
308
|
\`update_task("T3", "review", <summary of what you did>)\` — that **is** your
|
|
224
309
|
report to the coordinator; don't also send a message. If you are stuck, use
|
|
225
310
|
\`update_task("T3", "blocked", <why>)\`.
|
|
@@ -242,6 +327,7 @@ ${connectionsSection(connections)}
|
|
|
242
327
|
- Never start long-running processes (servers, watchers, anything that doesn't
|
|
243
328
|
exit on its own) in the foreground.
|
|
244
329
|
${PROCESS_RULES}
|
|
330
|
+
${KEEP_CONVERSATION_SMALL}
|
|
245
331
|
`;
|
|
246
332
|
}
|
|
247
333
|
/** First turn, sent by Symbio right after the coordinator is created, so it speaks first. */
|