symbio 0.7.1 → 0.8.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,28 @@ versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.8.0]
9
+
10
+ - Fixed: a multiple-choice question from an agent disappeared from its chat when another
11
+ agent's message arrived while it waited (only *Needs you* remained), and ten minutes later the
12
+ watchdog interrupted it as if it were hung. The agent now stays *waiting* until you answer, and
13
+ the messages wait their turn.
14
+ - Attach files to a message: the paperclip, drag and drop onto the chat, or paste a screenshot.
15
+ Agents get each file's path with how to open it; the sent message shows the files. Each project
16
+ has a **Files** tab with everything sent (preview, open, download, resend to an agent, delete).
17
+ - `read_document`: agents read PDFs (by pages), Word, Excel, PowerPoint and OpenDocument text a part
18
+ at a time. Reading PDFs with the read tool is refused like images, since it put the whole file in
19
+ the conversation.
20
+ - Folders of several git repositories (e.g. `client/` and `server/` with their own remotes): the
21
+ New project dialog shows them and no longer offers to create a repository on top, which hid
22
+ their files from helpers. Each helper works in a copy of one of them (`repo` on `spawn_agent`),
23
+ merges go back to that repository, `git_push` / `git_pull` take `repo`, and the daily check looks
24
+ for unpushed work in each.
25
+ - Projects added with an earlier version over a folder of repositories (Symbio created one on top
26
+ of them): after the update, core checks the project and switches it to the inner repositories on
27
+ its own when the outer one is Symbio's and has no remote, or asks you otherwise. Nothing on disk
28
+ changes, and Settings → Team switches back.
29
+
8
30
  ## [0.7.1]
9
31
 
10
32
  - On a Node.js older than 22, `sym` says what to install instead of Symbio crashing at start
package/README.md CHANGED
@@ -27,7 +27,9 @@ Budget*, run on free models: the conversations, plan and messages were produced
27
27
  mocked.
28
28
 
29
29
  **1. Start from a folder.** Pick an empty folder for something new, or an existing one for core to
30
- study first. Symbio can set up version history so helpers can work side by side.
30
+ study first. Symbio can set up version history so helpers can work side by side; a folder that
31
+ holds several git repositories (say `client/` and `server/`) is kept as it is, and helpers work
32
+ inside each one.
31
33
 
32
34
  ![New project dialog](docs/images/new-project.png)
33
35
 
@@ -64,6 +66,10 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
64
66
  database, a spreadsheet) can be declared a *shared asset*. Agents request it from core, wait for
65
67
  the grant and release it when done. Changes to an asset an agent wasn't granted are flagged at
66
68
  merge.
69
+ - **Attach files to a message.** Upload, drag in or paste images and documents into the chat, as in
70
+ other AI chats. Agents get them as files they can open (images as scaled-down copies, PDF, Word,
71
+ Excel and PowerPoint as text), and each project's **Files** tab keeps everything sent, to open,
72
+ resend or delete.
67
73
  - **Documentation you can find your way in.** core keeps the project's docs up to date, and the
68
74
  Docs tab shows them as a folder tree with search across every doc, links between docs that open
69
75
  in place, an outline of long docs, and plain-text sources (e-mails, transcripts) with line
@@ -187,7 +193,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
187
193
  | `~/.symbio/ui-token` | The key that unlocks the UI (it's in the link Symbio opens) |
188
194
  | `~/.symbio/logs/symbio.log` | Symbio's log when started in the background (`sym logs`); `update.log` next to it for updates |
189
195
  | `~/.symbio/updates.json` | Your update setting (automatic, tell me, off) |
190
- | `<project>/.symbio/` | Project state: agents, plan, message log, shared assets (git-ignored automatically) |
196
+ | `<project>/.symbio/` | Project state: agents, plan, message log, shared assets, and the files attached in chats (`attachments/`) — git-ignored automatically |
191
197
  | `<project>/AGENTS.md`, `<project>/docs/` | Project documentation for agents (unless you chose *private*), and `docs/rules.json` if the rules register is on |
192
198
  | OpenCode's database | The conversations themselves. Every agent is an OpenCode session, so you can also open one with `opencode -s <session-id>`. |
193
199
 
@@ -11,6 +11,7 @@ import { pickFolder } from "./folder-picker.js";
11
11
  import { beginOauth, cancelOauth, completeOauth, connectWithKey, listProviders, oauthStatus, removeLogin } from "./providers.js";
12
12
  import { docsRoot, listDocs, rawDoc, readDoc, searchDocs, writeDoc } from "./docs.js";
13
13
  import { readRegister } from "./rules.js";
14
+ import { addAttachment, attachmentNote, getAttachments, listAttachments, markSent, removeAttachment } from "./attachments.js";
14
15
  import { PriceBook, projectUsage, resolvePrices } from "./costs.js";
15
16
  import { globalDir } from "./store.js";
16
17
  // Debug routes pass OpenCode responses through, which can contain provider keys.
@@ -260,7 +261,8 @@ export function createApi(store, runtime, team, connections) {
260
261
  const directory = body.directory.trim();
261
262
  await fs.mkdir(directory, { recursive: true });
262
263
  let inspection = await inspectFolder(directory);
263
- if (body.initGit && !inspection.isGit) {
264
+ // A folder of repositories keeps them as they are: one on top would swallow them.
265
+ if (body.initGit && !inspection.isGit && !inspection.repos.length) {
264
266
  await initRepository(directory);
265
267
  inspection = { ...inspection, isGit: true };
266
268
  }
@@ -293,6 +295,14 @@ export function createApi(store, runtime, team, connections) {
293
295
  api.patch("/projects/:id", async (c) => {
294
296
  const project = withProject(c.req.param("id"));
295
297
  const body = await c.req.json();
298
+ if (typeof body.subRepositories === "boolean") {
299
+ try {
300
+ await team.useSubRepositories(project, body.subRepositories, "the human");
301
+ }
302
+ catch (err) {
303
+ return c.json({ error: err.message }, 400);
304
+ }
305
+ }
296
306
  if (typeof body.rulesRegister === "boolean")
297
307
  await team.setRulesRegister(project, body.rulesRegister);
298
308
  if (body.helperWorkspace === "own" || body.helperWorkspace === "shared")
@@ -624,11 +634,75 @@ export function createApi(store, runtime, team, connections) {
624
634
  const project = withProject(c.req.param("id"));
625
635
  const agent = withAgent(project, c.req.param("agentId"));
626
636
  const body = await c.req.json();
627
- if (!body.text?.trim())
637
+ const ids = Array.isArray(body.attachments) ? body.attachments.map(String) : [];
638
+ if (!body.text?.trim() && !ids.length)
628
639
  return c.json({ error: "text is required" }, 400);
629
- await team.sendFromHuman(project, agent, body.text.trim(), !!body.urgent);
640
+ let files;
641
+ try {
642
+ files = await getAttachments(project, ids);
643
+ }
644
+ catch (err) {
645
+ return c.json({ error: err.message }, 400);
646
+ }
647
+ const text = (body.text?.trim() || (files.length === 1 ? "Here's a file." : "Here are some files.")) + attachmentNote(files);
648
+ await team.sendFromHuman(project, agent, text, !!body.urgent);
649
+ if (ids.length) {
650
+ await markSent(project, ids, agent.id);
651
+ bus.publish({ type: "attachments.updated", projectId: project.id });
652
+ }
630
653
  return c.json({ ok: true });
631
654
  });
655
+ // ---- attachments: files the human sends in the chat, and the project's gallery of them
656
+ api.get("/projects/:id/attachments", async (c) => {
657
+ const project = withProject(c.req.param("id"));
658
+ // Paths stay on the server: the browser gets names, sizes and who received each file.
659
+ const list = await listAttachments(project);
660
+ return c.json(list.map(({ file: _file, ...rest }) => rest).reverse());
661
+ });
662
+ api.post("/projects/:id/attachments", async (c) => {
663
+ const project = withProject(c.req.param("id"));
664
+ const form = await c.req.parseBody({ all: true }).catch(() => null);
665
+ const files = [].concat(form?.files ?? form?.file ?? []).filter((f) => f instanceof File);
666
+ if (!files.length)
667
+ return c.json({ error: "Send the files as multipart form data (field \"files\")." }, 400);
668
+ try {
669
+ const added = [];
670
+ for (const f of files)
671
+ added.push(await addAttachment(project, f.name || "pasted", new Uint8Array(await f.arrayBuffer()), f.type));
672
+ bus.publish({ type: "attachments.updated", projectId: project.id });
673
+ return c.json(added.map(({ file: _file, ...rest }) => rest));
674
+ }
675
+ catch (err) {
676
+ return c.json({ error: err.message }, 400);
677
+ }
678
+ });
679
+ api.get("/projects/:id/attachments/:aid/file", async (c) => {
680
+ const project = withProject(c.req.param("id"));
681
+ const [a] = await getAttachments(project, [c.req.param("aid")]).catch(() => [null]);
682
+ if (!a)
683
+ throw new HttpError(404, "attachment not found");
684
+ // Shown inline only for types a browser displays safely; anything else downloads.
685
+ const inline = /^(image\/(png|jpe?g|gif|webp|bmp)|application\/pdf|text\/plain)$/.test(a.mime);
686
+ return new Response(new Uint8Array(await fs.readFile(a.file)), {
687
+ headers: {
688
+ "content-type": inline ? a.mime : "application/octet-stream",
689
+ "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(a.name)}`,
690
+ "x-content-type-options": "nosniff",
691
+ "content-security-policy": "default-src 'none'; img-src 'self' blob: data:; style-src 'unsafe-inline'",
692
+ },
693
+ });
694
+ });
695
+ api.delete("/projects/:id/attachments/:aid", async (c) => {
696
+ const project = withProject(c.req.param("id"));
697
+ try {
698
+ await removeAttachment(project, c.req.param("aid"));
699
+ bus.publish({ type: "attachments.updated", projectId: project.id });
700
+ return c.json({ ok: true });
701
+ }
702
+ catch (err) {
703
+ return c.json({ error: err.message }, 404);
704
+ }
705
+ });
632
706
  api.post("/projects/:id/agents/:agentId/interrupt", async (c) => {
633
707
  const project = withProject(c.req.param("id"));
634
708
  const agent = withAgent(project, c.req.param("agentId"));
@@ -0,0 +1,104 @@
1
+ // Files the human attaches to messages (uploaded, dropped or pasted in the chat), kept per project
2
+ // in .symbio/attachments (git-ignored with the rest of .symbio) with an index: the project's
3
+ // gallery. Agents get the files' full paths in the message, so helpers in their own copies can
4
+ // open them too; images through view_image, documents through read_document.
5
+ import { existsSync, promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import { IMAGE_EXTENSIONS } from "./guard.js";
8
+ import { DOCUMENT_EXTENSIONS } from "./documents.js";
9
+ import { newId } from "./store.js";
10
+ export const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
11
+ const dir = (project) => path.join(project.directory, ".symbio", "attachments");
12
+ const indexFile = (project) => path.join(project.directory, ".symbio", "attachments.json");
13
+ // One write at a time per project (several files are often dropped at once).
14
+ const queues = new Map();
15
+ function serial(project, fn) {
16
+ const run = (queues.get(project.id) ?? Promise.resolve()).then(fn);
17
+ queues.set(project.id, run.catch(() => { }));
18
+ return run;
19
+ }
20
+ export async function listAttachments(project) {
21
+ const raw = await fs.readFile(indexFile(project), "utf8").catch(() => "[]");
22
+ const all = JSON.parse(raw);
23
+ return all.filter((a) => existsSync(a.file));
24
+ }
25
+ async function save(project, list) {
26
+ await fs.mkdir(path.dirname(indexFile(project)), { recursive: true });
27
+ const tmp = `${indexFile(project)}.tmp`;
28
+ await fs.writeFile(tmp, JSON.stringify(list, null, 2));
29
+ await fs.rename(tmp, indexFile(project));
30
+ }
31
+ /** A file name safe on every OS, keeping the extension. */
32
+ export function safeName(name) {
33
+ const base = path.basename(name.replace(/\\/g, "/")).normalize("NFC");
34
+ const cleaned = base
35
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
36
+ .replace(/^\.+/, "")
37
+ .trim()
38
+ .slice(-120);
39
+ return cleaned || "file";
40
+ }
41
+ export async function addAttachment(project, name, data, mime) {
42
+ if (data.byteLength > MAX_ATTACHMENT_BYTES)
43
+ throw new Error(`${name} is larger than ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB.`);
44
+ return serial(project, async () => {
45
+ const id = newId("att");
46
+ const clean = safeName(name);
47
+ // The id keeps names unique; the readable name stays visible to agents and in file managers.
48
+ const file = path.join(dir(project), `${id.slice(4, 12)}-${clean}`);
49
+ await fs.mkdir(dir(project), { recursive: true });
50
+ await fs.writeFile(file, data);
51
+ const record = { id, name: clean, mime: mime || "application/octet-stream", size: data.byteLength, file, addedAt: Date.now(), sentTo: [] };
52
+ const list = await listAttachments(project);
53
+ list.push(record);
54
+ await save(project, list);
55
+ return record;
56
+ });
57
+ }
58
+ export async function getAttachments(project, ids) {
59
+ const all = await listAttachments(project);
60
+ const found = ids.map((id) => all.find((a) => a.id === id));
61
+ const missing = ids.filter((_, i) => !found[i]);
62
+ if (missing.length)
63
+ throw new Error(`Attachment not found: ${missing.join(", ")}`);
64
+ return found;
65
+ }
66
+ export async function markSent(project, ids, agentId) {
67
+ await serial(project, async () => {
68
+ const list = await listAttachments(project);
69
+ for (const a of list)
70
+ if (ids.includes(a.id))
71
+ a.sentTo.push({ agentId, at: Date.now() });
72
+ await save(project, list);
73
+ });
74
+ }
75
+ export async function removeAttachment(project, id) {
76
+ await serial(project, async () => {
77
+ const list = await listAttachments(project);
78
+ const target = list.find((a) => a.id === id);
79
+ if (!target)
80
+ throw new Error("Attachment not found");
81
+ await fs.rm(target.file, { force: true });
82
+ await save(project, list.filter((a) => a.id !== id));
83
+ });
84
+ }
85
+ const kb = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
86
+ /** What the agent reads below the human's message: each file's full path and how to open it. */
87
+ export function attachmentNote(files) {
88
+ if (!files.length)
89
+ return "";
90
+ const how = (a) => {
91
+ const ext = path.extname(a.name).slice(1).toLowerCase();
92
+ if (IMAGE_EXTENSIONS.includes(ext))
93
+ return "image: look at it with view_image";
94
+ if (ext === "pdf" || ["docx", "xlsx", "pptx", "odt"].includes(ext))
95
+ return "document: read it with read_document";
96
+ if (DOCUMENT_EXTENSIONS.includes(ext))
97
+ return "text: read it with read (or read_document for long ones)";
98
+ return "open it with the tools that fit its type";
99
+ };
100
+ // Seen live: without naming the tools' home, a model first tried webfetch and the read tool.
101
+ return (`\n\n[Attached by the human — ${files.length === 1 ? "a file" : `${files.length} files`}, saved in the project:]\n` +
102
+ files.map((a) => `- ${a.file} (${a.name}, ${kb(a.size)}; ${how(a)})`).join("\n") +
103
+ "\n(view_image and read_document are Symbio's tools: tools.symbio.view_image / tools.symbio.read_document, with the path above.)");
104
+ }
@@ -0,0 +1,166 @@
1
+ // read_document: the text of documents agents can't (or shouldn't) open with the read tool.
2
+ // OpenCode's read tool puts a whole PDF into the conversation as data (sent again every turn, like
3
+ // images) and refuses Office files as binary. This returns their text, a part at a time.
4
+ import { existsSync, promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ import { strFromU8, unzipSync } from "fflate";
7
+ import { isSecretPath } from "./guard.js";
8
+ export const DOCUMENT_EXTENSIONS = ["pdf", "docx", "xlsx", "pptx", "odt", "txt", "md", "csv", "tsv", "json", "xml", "html", "htm"];
9
+ const MAX_CHARS = 40_000; // per call; agents ask for the next part
10
+ const MAX_BYTES = 100 * 1024 * 1024;
11
+ export async function readDocument(folder, input) {
12
+ const given = String(input.path ?? "").trim();
13
+ if (!given)
14
+ throw new Error("Give the document's path.");
15
+ const file = path.resolve(folder, given);
16
+ const ext = path.extname(file).slice(1).toLowerCase();
17
+ if (!DOCUMENT_EXTENSIONS.includes(ext))
18
+ throw new Error(`Not a document read_document understands (${DOCUMENT_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 stat = await fs.stat(file);
24
+ if (stat.size > MAX_BYTES)
25
+ throw new Error(`${given} is ${Math.round(stat.size / 1024 / 1024)} MB; documents up to ${MAX_BYTES / 1024 / 1024} MB can be read.`);
26
+ const data = await fs.readFile(file);
27
+ if (ext === "pdf")
28
+ return readPdf(given, data, String(input.pages ?? ""));
29
+ const from = Math.max(0, Math.floor(Number(input.from) || 0));
30
+ let text;
31
+ let unit = "";
32
+ if (ext === "docx")
33
+ text = docxText(data);
34
+ else if (ext === "odt")
35
+ text = odtText(data);
36
+ else if (ext === "xlsx") {
37
+ text = xlsxText(data);
38
+ unit = " (sheets as tab-separated rows)";
39
+ }
40
+ else if (ext === "pptx")
41
+ text = pptxText(data);
42
+ else
43
+ text = data.toString("utf8");
44
+ return page(given, text, from, unit);
45
+ }
46
+ /** A slice of a long text, with how to get the next one. */
47
+ function page(name, text, from, unit = "") {
48
+ const total = text.length;
49
+ if (!total)
50
+ return `${name}: no text found${unit}.`;
51
+ const slice = text.slice(from, from + MAX_CHARS);
52
+ const end = from + slice.length;
53
+ const more = end < total ? `\n\n[… characters ${from + 1}–${end} of ${total}; for the next part call read_document with from=${end}]` : "";
54
+ return `${name}${unit}${from || more ? `, characters ${from + 1}–${end} of ${total}` : ""}:\n\n${slice}${more}`;
55
+ }
56
+ async function readPdf(name, data, pages) {
57
+ const { extractText, getDocumentProxy } = await import("unpdf");
58
+ const pdf = await getDocumentProxy(new Uint8Array(data));
59
+ const { totalPages, text } = await extractText(pdf, { mergePages: false });
60
+ const all = text;
61
+ let [first, last] = [1, totalPages];
62
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(pages);
63
+ if (m) {
64
+ first = Math.min(Math.max(1, Number(m[1])), totalPages);
65
+ last = Math.min(Math.max(first, Number(m[2] ?? m[1])), totalPages);
66
+ }
67
+ const out = [];
68
+ let used = 0;
69
+ let p = first;
70
+ for (; p <= last; p++) {
71
+ const t = `--- page ${p} ---\n${(all[p - 1] ?? "").trim()}`;
72
+ if (used + t.length > MAX_CHARS && out.length)
73
+ break;
74
+ out.push(t);
75
+ used += t.length;
76
+ }
77
+ const shown = p - 1;
78
+ const noText = all.every((t) => !t.trim());
79
+ const head = `${name}: ${totalPages} page${totalPages === 1 ? "" : "s"}, showing ${first}–${shown}.`;
80
+ if (noText)
81
+ return `${head} It has no text layer (probably scanned images): ask the human for a text version, or for the pages as images to look at with view_image.`;
82
+ const more = shown < totalPages ? `\n\n[… for more, call read_document with pages="${shown + 1}-${Math.min(totalPages, shown + 20)}"]` : "";
83
+ return `${head}\n\n${out.join("\n\n")}${more}`;
84
+ }
85
+ const unzip = (data) => unzipSync(new Uint8Array(data));
86
+ const decode = (s) => s
87
+ .replace(/&lt;/g, "<")
88
+ .replace(/&gt;/g, ">")
89
+ .replace(/&quot;/g, '"')
90
+ .replace(/&apos;/g, "'")
91
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
92
+ .replace(/&amp;/g, "&");
93
+ /** Word: paragraphs (w:p) of w:t runs; tabs and breaks kept; table cells separated by tabs. */
94
+ function docxText(data) {
95
+ const files = unzip(data);
96
+ const xml = files["word/document.xml"] ? strFromU8(files["word/document.xml"]) : "";
97
+ if (!xml)
98
+ throw new Error("This doesn't look like a Word document (no word/document.xml).");
99
+ return xml
100
+ .replace(/<w:tab\/>/g, "\t")
101
+ .replace(/<w:br[^>]*\/>/g, "\n")
102
+ .replace(/<\/w:tc>/g, "\t")
103
+ .replace(/<\/w:p>/g, "\n")
104
+ .replace(/<[^>]+>/g, "")
105
+ .split("\n")
106
+ .map((l) => decode(l).trimEnd())
107
+ .join("\n")
108
+ .replace(/\n{3,}/g, "\n\n")
109
+ .trim();
110
+ }
111
+ /** OpenDocument text: paragraphs and headings of content.xml. */
112
+ function odtText(data) {
113
+ const files = unzip(data);
114
+ const xml = files["content.xml"] ? strFromU8(files["content.xml"]) : "";
115
+ if (!xml)
116
+ throw new Error("This doesn't look like an OpenDocument text (no content.xml).");
117
+ return decode(xml.replace(/<text:tab\/>/g, "\t").replace(/<\/text:(p|h)>/g, "\n").replace(/<[^>]+>/g, "")).replace(/\n{3,}/g, "\n\n").trim();
118
+ }
119
+ /** Excel: every sheet, by name, as tab-separated rows (shared strings, inline strings, numbers). */
120
+ function xlsxText(data) {
121
+ const files = unzip(data);
122
+ const read = (p) => (files[p] ? strFromU8(files[p]) : "");
123
+ const shared = [...read("xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => decode(m[1].replace(/<[^>]+>/g, "")));
124
+ const workbook = read("xl/workbook.xml");
125
+ const rels = read("xl/_rels/workbook.xml.rels");
126
+ const target = (rid) => /Target="([^"]+)"/.exec(new RegExp(`<Relationship[^>]*Id="${rid}"[^>]*>`).exec(rels)?.[0] ?? "")?.[1];
127
+ const sheets = [...workbook.matchAll(/<sheet [^>]*name="([^"]*)"[^>]*r:id="([^"]+)"/g)].map((m) => ({ name: decode(m[1]), file: target(m[2]) }));
128
+ if (!sheets.length)
129
+ throw new Error("This doesn't look like an Excel workbook (no sheets found).");
130
+ const col = (ref) => [...(/^[A-Z]+/.exec(ref)?.[0] ?? "A")].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
131
+ return sheets
132
+ .map(({ name, file }) => {
133
+ const xml = file ? read(`xl/${file.replace(/^\/?xl\//, "")}`) : "";
134
+ const rows = [...xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)].map((r) => {
135
+ const cells = [];
136
+ for (const c of r[1].matchAll(/<c ([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
137
+ const attrs = c[1];
138
+ const body = c[2] ?? "";
139
+ const ref = /r="([A-Z]+)\d+"/.exec(attrs)?.[1] ?? "";
140
+ const type = /t="([^"]+)"/.exec(attrs)?.[1];
141
+ const v = /<v>([\s\S]*?)<\/v>/.exec(body)?.[1];
142
+ const value = type === "s" ? shared[Number(v)] ?? "" : type === "inlineStr" ? decode(body.replace(/<[^>]+>/g, "")) : decode(v ?? "");
143
+ cells[ref ? col(ref) : cells.length] = value.replace(/\s*\n\s*/g, " ");
144
+ }
145
+ return Array.from(cells, (x) => x ?? "").join("\t").trimEnd();
146
+ });
147
+ return `## ${name}\n${rows.filter((r) => r).join("\n")}`;
148
+ })
149
+ .join("\n\n");
150
+ }
151
+ /** PowerPoint: the text of each slide, in order. */
152
+ function pptxText(data) {
153
+ const files = unzip(data);
154
+ const slides = Object.keys(files)
155
+ .filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))
156
+ .sort((a, b) => Number(/(\d+)\.xml$/.exec(a)[1]) - Number(/(\d+)\.xml$/.exec(b)[1]));
157
+ if (!slides.length)
158
+ throw new Error("This doesn't look like a PowerPoint presentation (no slides found).");
159
+ return slides
160
+ .map((f, i) => {
161
+ const xml = strFromU8(files[f]);
162
+ const text = decode(xml.replace(/<\/a:p>/g, "\n").replace(/<[^>]+>/g, "")).replace(/\n{2,}/g, "\n").trim();
163
+ return `--- slide ${i + 1} ---\n${text}`;
164
+ })
165
+ .join("\n\n");
166
+ }
@@ -107,20 +107,33 @@ const SECRET_SHELL = [
107
107
  // Patterns are case-sensitive, hence both spellings.
108
108
  export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"];
109
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))))
110
+ // PDFs the same way (the read tool attaches the whole file as data; verified with OpenCode 2.1.4):
111
+ // agents read their text with read_document, a part at a time.
112
+ const PDF_PATHS = ["*.pdf", "*.PDF"];
113
+ /** A read Symbio refuses on the agent's behalf (an image or a PDF), with what to do instead. */
114
+ export function readRefusal(action, resources) {
115
+ if (action !== "read" || !resources.length)
113
116
  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
+ const all = (patterns) => resources.every((r) => patterns.some((p) => wildcardMatch(p, r)));
118
+ const file = JSON.stringify(resources[0]);
119
+ if (all(IMAGE_PATHS)) {
120
+ return ("Symbio blocks reading image files directly: a full-size image stays in your conversation and is sent again on every " +
121
+ `turn. Use Symbio's view_image tool instead (in code mode: tools.symbio.view_image({ path: ${file} })); ` +
122
+ "it returns a scaled-down copy, and a crop shows details at full resolution.");
123
+ }
124
+ if (all(PDF_PATHS)) {
125
+ return ("Symbio blocks reading PDFs directly: the whole file would stay in your conversation and be sent again on every turn. " +
126
+ `Use Symbio's read_document tool instead (in code mode: tools.symbio.read_document({ path: ${file} })); ` +
127
+ 'it returns the text, a few pages at a time (pages: "3-8").');
128
+ }
129
+ return null;
117
130
  }
118
131
  const rules = (action, effect, patterns) => patterns.map((resource) => ({ action, resource, effect }));
119
132
  // Any matching deny blocks a call; otherwise any ask prompts. Deny rules come last
120
133
  // so they also win under "last match wins".
121
134
  export const AGENT_PERMISSIONS = [
122
135
  ...rules("shell", "ask", INSTALL_PATTERNS),
123
- ...rules("read", "ask", IMAGE_PATHS), // refused by Symbio with a message (imageReadRefusal)
136
+ ...rules("read", "ask", [...IMAGE_PATHS, ...PDF_PATHS]), // refused by Symbio with a message (readRefusal)
124
137
  ...rules("shell", "deny", KILL_PATTERNS),
125
138
  ...rules("shell", "deny", SECRET_SHELL),
126
139
  ...rules("read", "deny", SECRET_PATHS),
@@ -4,6 +4,7 @@
4
4
  import { existsSync, promises as fs } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { git } from "./git.js";
7
+ import { projectRepositoriesSync } from "./repos.js";
7
8
  import { globalDir } from "./store.js";
8
9
  // ---------------------------------------------------------------- backups
9
10
  const BACKUP_KEEP = Number(process.env.SYMBIO_BACKUP_DAYS ?? 7);
@@ -54,21 +55,38 @@ export async function lastBackup() {
54
55
  }
55
56
  /** The project's current branch and the helpers' branches, with commits that exist only here. */
56
57
  export async function unpushedWork(project) {
57
- if (!existsSync(path.join(project.directory, ".git")))
58
+ // Several repositories: each one against its own remote, branches named "repo: branch".
59
+ const repos = projectRepositoriesSync(project);
60
+ if (!repos.length) {
61
+ if (existsSync(path.join(project.directory, ".git")))
62
+ return unpushedIn(project.directory, "");
58
63
  return { remote: null, branches: [] };
59
- const remote = (await git(project.directory, "remote").catch(() => "")).split("\n")[0]?.trim() || null;
64
+ }
65
+ const remotes = new Set();
66
+ const branches = [];
67
+ for (const repo of repos) {
68
+ const found = await unpushedIn(path.join(project.directory, ...repo.split("/")), `${repo}: `);
69
+ if (!found.remote)
70
+ continue;
71
+ remotes.add(found.remote);
72
+ branches.push(...found.branches);
73
+ }
74
+ return { remote: remotes.size === 0 ? null : remotes.size === 1 ? [...remotes][0] : "each repository's remote", branches };
75
+ }
76
+ async function unpushedIn(dir, label) {
77
+ const remote = (await git(dir, "remote").catch(() => "")).split("\n")[0]?.trim() || null;
60
78
  if (!remote)
61
79
  return { remote: null, branches: [] };
62
- const current = await git(project.directory, "rev-parse", "--abbrev-ref", "HEAD").catch(() => "");
63
- const helpers = (await git(project.directory, "branch", "--list", "symbio/*", "--format=%(refname:short)").catch(() => ""))
80
+ const current = await git(dir, "rev-parse", "--abbrev-ref", "HEAD").catch(() => "");
81
+ const helpers = (await git(dir, "branch", "--list", "symbio/*", "--format=%(refname:short)").catch(() => ""))
64
82
  .split("\n")
65
83
  .map((b) => b.trim())
66
84
  .filter(Boolean);
67
85
  const branches = [];
68
86
  for (const branch of [...new Set([current, ...helpers].filter((b) => b && b !== "HEAD"))]) {
69
- const commits = Number(await git(project.directory, "rev-list", "--count", branch, "--not", "--remotes").catch(() => "0")) || 0;
87
+ const commits = Number(await git(dir, "rev-list", "--count", branch, "--not", "--remotes").catch(() => "0")) || 0;
70
88
  if (commits > 0)
71
- branches.push({ branch, commits, latest: await git(project.directory, "log", "-1", "--format=%s", branch).catch(() => "") });
89
+ branches.push({ branch: label + branch, commits, latest: await git(dir, "log", "-1", "--format=%s", branch).catch(() => "") });
72
90
  }
73
91
  return { remote, branches };
74
92
  }
@@ -2,6 +2,7 @@
2
2
  // content does it hold, and does it already have guidance for agents/humans.
3
3
  import { promises as fs, existsSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { findRepositories, swallowedRepositories } from "./repos.js";
5
6
  const KIND_BY_EXT = {};
6
7
  const add = (kind, exts) => exts.split(" ").forEach((e) => (KIND_BY_EXT[e] = kind));
7
8
  add("documents", ".md .markdown .txt .doc .docx .odt .rtf .pdf .tex .rst .adoc .epub .pages");
@@ -41,6 +42,7 @@ export async function inspectFolder(input) {
41
42
  exists: false,
42
43
  empty: true,
43
44
  isGit: false,
45
+ repos: [],
44
46
  fileCount: 0,
45
47
  totalBytes: 0,
46
48
  truncated: false,
@@ -54,6 +56,13 @@ export async function inspectFolder(input) {
54
56
  return result;
55
57
  result.exists = true;
56
58
  result.isGit = existsSync(path.join(directory, ".git"));
59
+ if (!result.isGit)
60
+ result.repos = await findRepositories(directory);
61
+ else {
62
+ const swallowed = await swallowedRepositories(directory);
63
+ if (swallowed.inner.length)
64
+ result.swallowed = swallowed;
65
+ }
57
66
  const top = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
58
67
  const visibleTop = top.filter((e) => e.name !== ".symbio");
59
68
  result.empty = visibleTop.length === 0;
@@ -121,7 +130,11 @@ export function describeInspection(i) {
121
130
  `Most common extensions: ${i.topExtensions.map((e) => `${e.ext} (${e.count})`).join(", ") || "none"}.`,
122
131
  `Top level: ${i.topLevel.join(", ")}.`,
123
132
  i.guidance.length ? `Existing guidance: ${i.guidance.join(", ")}.` : "No README, AGENTS.md or docs folder.",
124
- i.isGit ? "It is a git repository." : "It is not a git repository.",
133
+ i.isGit
134
+ ? "It is a git repository."
135
+ : i.repos.length
136
+ ? `It is not a git repository itself; it holds ${i.repos.length} git repositories: ${i.repos.join(", ")}.`
137
+ : "It is not a git repository.",
125
138
  ];
126
139
  return lines.join("\n");
127
140
  }