symbiot 0.59.0 → 0.61.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
@@ -2,6 +2,17 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.61.0
6
+
7
+ - **The Symbiot Browser.** Where you sign in to your sites for Symbiot, now a window of its own, named and marked as Symbiot's, instead of a plain Chrome window that looked like any other. It opens on a page of your sites (Gmail, Outlook, calendars, GitHub, Slack, WhatsApp, Jira, LinkedIn…), each saying whether you're signed in, with a **Sign in** (or **Connect**) button, an address bar for any other site, and **Done**, which closes it and has Symbiot check each site. Open it from **Settings → Links → Open the Symbiot Browser**, or `symbiot browser [site]`; connecting a site anywhere in Symbiot opens it too.
8
+ - **No more invisible sign-in windows.** If Symbiot's hidden browser was running, a sign-in window could open inside it, where you'd never see it. It's closed first now.
9
+ - **No more stray empty windows.** While the Symbiot Browser (or any sign-in window) is open, Symbiot's background reads wait for Done instead of starting a second Chrome, which popped an empty window into yours.
10
+
11
+ ## 0.60.0
12
+
13
+ - **A release no longer trips on file times.** Linux stamps files with a clock a few milliseconds behind the precise one, so a run that asked a question straight away could look as if it asked before it started, and wasn't noted as waiting on you. Symbiot now allows for that (it also made a test fail on GitHub's machines, which held up 0.59.0).
14
+ - **Symbiot in any language.** It uses your computer's language, or any you name in **Settings → Language** (Afrikaans, isiZulu, Português, 日本語…). Chats, the week, standups and to-dos are written in it, and a chat answers in whatever language you write to it in. The app's own words (every screen, button and message) are translated by your AI the first time they're shown, with its fastest model, in about half a minute, then kept on your computer, so it's instant after that. A small note says it's translating while it does. Your own content (tasks, chats, drafts, write-ups, names, code) is never translated, and drafts stay exactly as they'll be sent.
15
+
5
16
  ## 0.59.0
6
17
 
7
18
  - **Symbiot Free and Symbiot Pro.** Every install starts with 14 days of Pro. After that, Free keeps the orb, Home, Talk to Symbiot, your week and standups, voice and the Away screen, with one agent at a time, up to 3 projects and one connected inbox or chat. Pro (US$12 a month or US$99 a year) adds any number of agents at once, every project, every inbox and chat, the marketing lane and Approve-and-ship. **Settings → Symbiot Pro** shows where you stand, takes a key and lets you swap one of Free's 3 projects. A limit never stops anything running: the next thing just doesn't start, with a line saying why. Keys are checked offline (signed by Ghost AI; cancelled keys are listed on symbiot.co.za).
package/README.md CHANGED
@@ -1419,6 +1419,25 @@ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, b
1419
1419
  it can't read the scale on Wayland, and ydotool's moves follow your pointer
1420
1420
  acceleration, so a click there can land off target.
1421
1421
 
1422
+ ## The Symbiot Browser
1423
+
1424
+ Symbiot reads your sites (Gmail, LinkedIn, WhatsApp, Jira…) with your own sign-in, in
1425
+ its own browser. **Settings → Links → Open the Symbiot Browser** (or `symbiot browser`)
1426
+ opens it: a window of its own listing your sites, each with whether you're signed in and
1427
+ a **Sign in** button, plus an address bar for any other site. Sign in the usual way
1428
+ (the site's own login and 2FA; Symbiot never sees a password), then click **Done**: it
1429
+ closes and Symbiot checks each site.
1430
+
1431
+ ## Any language
1432
+
1433
+ Symbiot uses the language your computer is set to, or any language you name in
1434
+ **Settings → Language** ("Afrikaans", "isiZulu", "pt-BR", anything). Chats, your week,
1435
+ standups and to-dos are written in it (a chat answers in whatever language you write to
1436
+ it in), and the app's own words are translated by your AI the first time you see them
1437
+ in that language (about half a minute, with your AI's fastest model), then kept in
1438
+ `~/.config/symbiot/i18n/`, so it's instant after that. Your own content (tasks, chats,
1439
+ drafts, write-ups, names, code) is never touched.
1440
+
1422
1441
  ## Symbiot Free and Pro
1423
1442
 
1424
1443
  Every install starts with **14 days of Pro**, no key and no card. After that Symbiot
package/agents.mjs CHANGED
@@ -711,9 +711,12 @@ function runInputs(path, cmd) {
711
711
  for (const f of files) { try { env += f + "\0" + readFileSync(join(path, f), "utf8") + "\0"; } catch {} }
712
712
  return { env: digest(env), answers: digest(readSymbiot(path, "ANSWERS.md")), cmd: digest(cmd), open: openTasks(path) };
713
713
  }
714
+ const MTIME_SLACK = 50; // ms: a kernel tick is 4-10 ms
714
715
  function noteBlocked(path, cmd, startedAt, code) {
715
716
  const f = join(path, ".symbiot", BLOCKED);
716
- let asked = false; try { asked = statSync(join(path, ".symbiot", "QUESTIONS.md")).mtimeMs >= startedAt; } catch {}
717
+ // file times come from the kernel's coarse clock, a few ms behind Date.now(): a run that
718
+ // asks at once can stamp QUESTIONS.md "before" it started; a run before it is far older
719
+ let asked = false; try { asked = statSync(join(path, ".symbiot", "QUESTIONS.md")).mtimeMs >= startedAt - MTIME_SLACK; } catch {}
717
720
  const questions = asked ? agentQuestions(path, "").questions.length : 0, inputs = runInputs(path, cmd);
718
721
  try {
719
722
  if (code === 0 && questions && inputs.open.length) writeFileSync(f, JSON.stringify({ at: Date.now(), questions, ...inputs }));
package/ai.mjs CHANGED
@@ -207,8 +207,10 @@ const AI_UI = {
207
207
  notConnected: () => console.log("Symbiot needs an AI to write your updates. With a Claude subscription, sign in to Claude Code (run claude once) and Symbiot uses it, no key. Otherwise: symbiot login"),
208
208
  rejected: (label) => console.log(`Your ${label} credentials were rejected. Reconnect with: symbiot login --force`),
209
209
  };
210
- async function write(system, prompt, { images = [] } = {}) {
211
- const r = resolveProvider();
210
+ // fast: a quick, plain job (translating the app's words): the provider's fastest model
211
+ const FAST_MODEL = { claude: "haiku", anthropic: "claude-haiku-5-5" };
212
+ async function write(system, prompt, { images = [], fast = false } = {}) {
213
+ const r0 = resolveProvider(), r = fast && r0 && FAST_MODEL[r0.provider] ? { ...r0, model: FAST_MODEL[r0.provider] } : r0;
212
214
  if (!r) { AI_UI.notConnected(); return null; }
213
215
  const stop = AI_UI.spinner("thinking…");
214
216
  try {
package/browser.html ADDED
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Symbiot Browser</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <meta name="theme-color" content="#0B0C0E">
9
+ <style>
10
+ /* The Symbiot Browser's own page: where you sign in to your sites, once, for Symbiot.
11
+ It runs in Symbiot's browser profile, so a sign-in here is the one Symbiot uses. */
12
+ @font-face{font-family:"Geist";src:url("/fonts/Geist-Variable.woff2") format("woff2");font-weight:100 900;font-display:swap}
13
+ :root{--bg:#0B0C0E;--panel:#131518;--line:#2A2E34;--bone:#F3F0EA;--text:#C9CDD3;--faint:#8A919B;--amber:#F2A541;--ok:#4FBE8C;--bad:#FF8A75;color-scheme:dark;
14
+ --sans:"Geist",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
15
+ @media (prefers-color-scheme: light){:root{--bg:#F4F2EE;--panel:#FFFFFF;--line:#DEDAD2;--bone:#11151B;--text:#3A414C;--faint:#6B7380;--amber:#B86A0C;--ok:#0B7A55;--bad:#B4361F;color-scheme:light}}
16
+ *{box-sizing:border-box}
17
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--sans)}
18
+ .wrap{max-width:880px;margin:0 auto;padding:28px 24px 40px;display:grid;gap:22px}
19
+ header{display:flex;align-items:center;gap:14px}
20
+ header img{width:44px;height:44px;border-radius:12px}
21
+ h1{margin:0;font:650 24px/1.2 var(--sans);letter-spacing:-.02em;color:var(--bone)}
22
+ .lede{margin:2px 0 0;color:var(--faint);font-size:14px;max-width:62ch}
23
+ form.go{display:flex;gap:10px}
24
+ form.go input{flex:1;min-width:0;padding:12px 16px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--bone);font:15px var(--sans)}
25
+ button{font:600 14px var(--sans);border-radius:999px;padding:10px 18px;border:1px solid var(--line);background:transparent;color:var(--bone);cursor:pointer}
26
+ button.main{background:var(--bone);color:var(--bg);border-color:var(--bone)}
27
+ button:focus-visible,input:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
28
+ h2{margin:6px 0 0;font:600 12px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
29
+ .sites{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
30
+ .site{display:flex;align-items:center;gap:12px;padding:14px 16px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}
31
+ .site .n{flex:1;min-width:0}
32
+ .site b{display:block;color:var(--bone);font-weight:600}
33
+ .site small{display:block;color:var(--faint);font-size:12.5px}
34
+ .site small.ok{color:var(--ok)}.site small.bad{color:var(--bad)}.site small.wait{color:var(--amber)}
35
+ .site button{padding:8px 14px;font-size:13px;flex:none}
36
+ .done{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:18px 20px;border-radius:18px;border:1px solid var(--line);background:var(--panel)}
37
+ .done p{margin:0;flex:1;min-width:220px;font-size:14px}
38
+ .msg{min-height:20px;font-size:14px;color:var(--amber)}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <main class="wrap">
43
+ <header>
44
+ <img src="/favicon.svg" alt="">
45
+ <div><h1>Symbiot Browser</h1>
46
+ <p class="lede">Symbiot’s own browser. Sign in to your sites here, once, the usual way (their own login, 2FA and all). Symbiot then reads them for you with that sign-in. It never sees your passwords.</p></div>
47
+ </header>
48
+
49
+ <form class="go" id="go"><input id="addr" placeholder="Go to any site: a name like gmail, or an address" autocomplete="off" aria-label="Site to open"><button class="main" type="submit">Open</button></form>
50
+ <div class="msg" id="msg" role="status"></div>
51
+
52
+ <section id="list"></section>
53
+
54
+ <div class="done">
55
+ <p><b>Signed in?</b> Click Done: the Symbiot Browser closes and Symbiot checks each site, so the ticks come up in Symbiot within a minute.</p>
56
+ <button class="main" id="done" type="button">Done</button>
57
+ </div>
58
+ </main>
59
+ <script>
60
+ var T = new URLSearchParams(location.search).get("t") || "";
61
+ function api(path, body) { return fetch(path, { method: body ? "POST" : "GET", headers: { "x-symbiot-token": T, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }).then(function (r) { return r.json(); }); }
62
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
63
+ function say(t) { document.getElementById("msg").textContent = t || ""; }
64
+ // a window of this same browser (so the sign-in is Symbiot's), the size of a laptop screen
65
+ function openSite(url) { var w = window.open(url, "_blank", "popup=no,width=1200,height=860"); if (!w) say("Your browser blocked the window: allow pop-ups for this page, then try again."); return w; }
66
+ function fromInput(v) { v = String(v || "").trim(); if (!v) return ""; if (/^https?:\/\//i.test(v)) return v; if (/^[a-z0-9-]+$/i.test(v)) return "https://" + v.toLowerCase() + ".com"; return "https://" + v; }
67
+ var STATE = { off: ["Not connected", ""], signin: ["Not signed in yet", "wait"], signedout: ["Signed out", "bad"], error: ["Needs attention", "bad"], ok: ["Signed in", "ok"] };
68
+ function render(d) {
69
+ var items = (d && d.items) || [], groups = (d && d.groups) || [], h = "";
70
+ groups.forEach(function (g) {
71
+ var its = items.filter(function (x) { return x.group === g; }); if (!its.length) return;
72
+ h += "<h2>" + esc(g) + "</h2><div class='sites'>" + its.map(function (x) {
73
+ var s = STATE[x.state] || STATE.off, label = x.state === "off" ? "Connect" : x.state === "ok" ? "Open" : "Sign in";
74
+ return "<div class='site'><div class='n'><b>" + esc(x.name) + "</b><small class='" + s[1] + "'>" + (x.state === "ok" ? "✓ " : "") + esc(s[0]) + "</small></div><button type='button' data-id='" + esc(x.id) + "' data-url='" + esc(x.url) + "' data-state='" + esc(x.state) + "'>" + label + "</button></div>";
75
+ }).join("") + "</div>";
76
+ });
77
+ document.getElementById("list").innerHTML = h;
78
+ }
79
+ function load() { api("/api/links").then(render).catch(function () { say("Symbiot isn't answering. Is it still running?"); }); }
80
+ document.getElementById("list").addEventListener("click", function (e) {
81
+ var b = e.target.closest && e.target.closest("button[data-id]"); if (!b) return;
82
+ var url = b.getAttribute("data-url"), id = b.getAttribute("data-id");
83
+ var w = openSite(url); // first, while the click still counts (pop-up rules)
84
+ if (b.getAttribute("data-state") === "off") api("/api/links/link", { id: id, here: true }).then(function (r) {
85
+ if (r && r.error) { if (w) w.close(); say(r.error); } else { say("Sign in to it in the window that opened, then come back here."); load(); }
86
+ }).catch(function () {});
87
+ else say("Sign in to it in the window that opened, then come back here.");
88
+ });
89
+ document.getElementById("go").addEventListener("submit", function (e) { e.preventDefault(); var u = fromInput(document.getElementById("addr").value); if (u) openSite(u); });
90
+ document.getElementById("done").addEventListener("click", function () { say("Closing, and checking your sites…"); api("/api/browser/done", {}).catch(function () {}); });
91
+ load(); setInterval(load, 5000);
92
+ </script>
93
+ </body>
94
+ </html>
package/headless.mjs CHANGED
@@ -19,9 +19,9 @@
19
19
  // The browser keeps its own profile in ~/.config/symbiot/browser: sign in to a
20
20
  // site once there (signIn opens it as a normal window) and later maps see it
21
21
  // signed in. It's separate from your everyday browser profile.
22
- import { spawn } from "node:child_process";
22
+ import { spawn, spawnSync } from "node:child_process";
23
23
  import { join, resolve as resolvePath } from "node:path";
24
- import { mkdirSync, existsSync, statSync } from "node:fs";
24
+ import { mkdirSync, existsSync, statSync, readlinkSync } from "node:fs";
25
25
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
26
26
  import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
27
27
 
@@ -98,6 +98,18 @@ const browserOpen = () => !!(live && !live.c.closed());
98
98
  // Don't leave it running when Symbiot exits (a restart, Ctrl+C).
99
99
  process.once("exit", () => { if (live && live.proc.exitCode === null) try { live.proc.kill("SIGKILL"); } catch {} });
100
100
 
101
+ // Is a visible Chrome (the Symbiot Browser) using Symbiot's profile? Its SingletonLock names the process
102
+ // ("host-1234", Linux and Mac); a lock whose process has gone is a leftover.
103
+ const OWN = new Set(); // every hidden Chrome this Symbiot started
104
+ function windowOpen() {
105
+ try {
106
+ const m = /-(\d+)$/.exec(readlinkSync(join(PROFILE, "SingletonLock")));
107
+ if (!m) return false;
108
+ if (OWN.has(Number(m[1]))) return false; // our own hidden browser (running, or still closing) isn't a window
109
+ process.kill(Number(m[1]), 0);
110
+ return true;
111
+ } catch { return false; }
112
+ }
101
113
  // Start the hidden browser and open a tab: { proc, c, page: { send, until, idle } }.
102
114
  async function launch() {
103
115
  const chrome = chromeBinary();
@@ -105,7 +117,11 @@ async function launch() {
105
117
  ? "Mapping a page needs a desktop browser (Chrome, Chromium, Edge or Brave), which a phone doesn't have. Map pages from Symbiot on your computer."
106
118
  : "Mapping a page needs Chrome, Chromium, Edge or Brave, and none was found.");
107
119
  mkdirSync(PROFILE, { recursive: true, mode: 0o700 });
120
+ // the Symbiot Browser (or a sign-in window) has the profile: a second Chrome would hand
121
+ // over to it and pop an empty window into it, so wait for Done instead
122
+ if (windowOpen()) throw new Error("The Symbiot Browser is open: Symbiot reads your sites once you click Done in it.");
108
123
  const proc = spawn(chrome, browserArgs(true), { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
124
+ if (proc.pid) OWN.add(proc.pid);
109
125
  let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
110
126
  const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
111
127
  const c = connect(proc), b = { proc, c, shown: "", timer: null };
@@ -801,13 +817,53 @@ function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
801
817
  // accept cookies) once. Close it before mapping: the profile is shared. The hidden
802
818
  // browser is closed first, or the window would open in it, out of sight.
803
819
  async function signIn(input) {
804
- const url = siteUrl(input), chrome = chromeBinary();
820
+ const url = siteUrl(input);
805
821
  if (!url) return { error: "Give the site to sign in to: a name (gmail), a host or a web address." };
806
- if (!chrome) return { error: "Signing in needs Chrome, Chromium, Edge or Brave, and none was found." };
822
+ return openSymbiotBrowser(url);
823
+ }
824
+
825
+ // ---- the Symbiot Browser --------------------------------------------------------
826
+ // Where you sign in to your sites for Symbiot: a window of its own, named and marked
827
+ // as Symbiot's, on Symbiot's browser profile (so its sign-ins are the ones the hidden
828
+ // browser reads with). It opens on its own page (browser.html, served by the app:
829
+ // your sites, signed in or not, a Sign in for each, an address bar, Done), plus the
830
+ // site asked for in a window beside it. Chrome lets one copy use a profile at a time,
831
+ // and a window asked of a hidden copy opens hidden: so any hidden copy on this profile
832
+ // is closed first, Symbiot's or a stray one (Linux, Mac).
833
+ let HUB = ""; // the app's address for that page: http://127.0.0.1:<port>/browser?t=<token>
834
+ function setBrowserHub(url) { HUB = String(url || ""); }
835
+ function profilePids({ headless } = {}) {
836
+ if (process.platform === "win32") return [];
837
+ try {
838
+ const out = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 }).stdout || "";
839
+ return out.split("\n").map((l) => l.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean)
840
+ .filter(([, , a]) => a.includes("--user-data-dir=" + PROFILE) && !/--type=/.test(a) && (headless === undefined || /--headless/.test(a) === headless))
841
+ .map(([, pid]) => Number(pid)).filter((p) => p && p !== process.pid);
842
+ } catch { return []; }
843
+ }
844
+ const kill = (pids) => { for (const p of pids) try { process.kill(p, "SIGTERM"); } catch {} };
845
+ async function openSymbiotBrowser(url = "") {
846
+ const chrome = chromeBinary();
847
+ if (!chrome) return { error: "The Symbiot Browser needs Chrome, Chromium, Edge or Brave on this computer, and none was found." };
807
848
  await oneAtATime(closeBrowser);
808
- try { mkdirSync(PROFILE, { recursive: true, mode: 0o700 }); spawn(chrome, browserArgs(false, url), { detached: true, stdio: "ignore" }).unref(); }
809
- catch (e) { return { error: String((e && e.message) || e) }; }
810
- return { ok: true, url };
849
+ const hidden = profilePids({ headless: true }); if (hidden.length) { kill(hidden); await sleep(800); }
850
+ try {
851
+ mkdirSync(PROFILE, { recursive: true, mode: 0o700 });
852
+ const base = ["--user-data-dir=" + PROFILE, "--no-first-run", "--no-default-browser-check",
853
+ ...(process.platform === "linux" ? ["--password-store=basic", "--class=SymbiotBrowser"] : process.platform === "darwin" ? ["--use-mock-keychain"] : [])];
854
+ const page = HUB ? ["--app=" + HUB, "--window-size=1040,820"] : [];
855
+ if (page.length) spawn(chrome, [...base, ...page], { detached: true, stdio: "ignore" }).unref();
856
+ if (url) setTimeout(() => { try { spawn(chrome, [...base, "--new-window", url], { detached: true, stdio: "ignore" }).unref(); } catch {} }, page.length ? 1500 : 0);
857
+ if (!page.length && !url) spawn(chrome, [...base, "--new-window", "about:blank"], { detached: true, stdio: "ignore" }).unref();
858
+ } catch (e) { return { error: String((e && e.message) || e) }; }
859
+ return { ok: true, ...(url ? { url } : {}), browser: true };
860
+ }
861
+ // Done: the Symbiot Browser closes (its sign-ins are saved), so the hidden browser can read with them.
862
+ async function closeSymbiotBrowser() {
863
+ const open = profilePids({ headless: false });
864
+ kill(open);
865
+ for (let i = 0; i < 20 && profilePids({ headless: false }).length; i++) await sleep(250);
866
+ return { closed: open.length };
811
867
  }
812
868
 
813
- export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
869
+ export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub };
package/index.mjs CHANGED
@@ -351,6 +351,15 @@ async function cmdAway() {
351
351
  if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Start it with symbiot app, then Away works.")); process.exitCode = 1; return; }
352
352
  if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
353
353
  }
354
+ // symbiot browser [site]: the Symbiot Browser, where you sign in to your sites for
355
+ // Symbiot (headless.mjs openSymbiotBrowser), opened by the running app.
356
+ async function cmdBrowser() {
357
+ const site = argv.slice(1).find((a) => !a.startsWith("-")) || "";
358
+ const r = await viaApp("/api/browser/open", site ? { url: site } : {});
359
+ if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Open Symbiot first, then the Symbiot Browser opens from Settings → Links, or with symbiot browser.")); process.exitCode = 1; return; }
360
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
361
+ console.log(`${c.g("✓")} Opened the Symbiot Browser${site ? " at " + (r.url || site) : ""}. Sign in to your sites there, then click Done.`);
362
+ }
354
363
  // symbiot open: what the app-menu icon runs. Running: its window comes up. Not
355
364
  // running: it starts in the background (no terminal; what it prints goes to
356
365
  // ~/.config/symbiot/app.log) and opens its own window.
@@ -446,7 +455,8 @@ async function cmdScreens() {
446
455
  symbiot screens whole <id> map all of that page in one tall screenshot
447
456
  (where a list scrolls inside the page, like
448
457
  Gmail's mail, that list opened out)
449
- symbiot screens signin <site> sign in once, in Symbiot's browser window
458
+ symbiot browser [site] the Symbiot Browser: sign in to your sites for Symbiot
459
+ symbiot screens signin <site> sign in once, in the Symbiot Browser
450
460
  --yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
451
461
  While the app runs, these use its hidden browser, which stays open a few minutes:
452
462
  press on the screen the last command printed carries on from that page as it is
@@ -926,6 +936,7 @@ async function main() {
926
936
  // an old copy npm replaced), and Claude Code won't run from a deleted folder
927
937
  if (cmd === "app" || cmd === "ui") { try { process.chdir(homedir()); } catch {} return cmdApp(); }
928
938
  if (cmd === "away") return cmdAway();
939
+ if (cmd === "browser") return cmdBrowser();
929
940
  if (cmd === "open") return cmdOpen();
930
941
  if (cmd === "uninstall") return cmdUninstall();
931
942
  if (cmd === "models" || cmd === "hardware") return cmdModels();
package/lang.mjs ADDED
@@ -0,0 +1,101 @@
1
+ // symbiot — Symbiot in any language.
2
+ //
3
+ // The language is your computer's (the app window reports it), unless you name one in
4
+ // Settings: any language at all, by name or code ("Afrikaans", "isiZulu", "pt-BR").
5
+ // Two things follow it:
6
+ // - what Symbiot writes: chats, the week, standups and to-dos come out in it (and a
7
+ // chat answers in whatever language you write to it in);
8
+ // - the app itself: its own words, translated by your AI the first time they're
9
+ // shown, then kept in ~/.config/symbiot/i18n/<language>.json, so they're instant
10
+ // after that. Your content (tasks, chats, drafts, names, code) is never touched.
11
+ // Numbers are kept out of what's translated ("3 at work" and "5 at work" are one
12
+ // string, "{#} at work"), so a page's counts changing costs nothing.
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
16
+ import { write } from "./ai.mjs";
17
+
18
+ const DIR = () => join(CONFIG_DIR, "i18n");
19
+ const BATCH = 40, PARALLEL = 6, MAX_LEN = 400;
20
+ const clean = (s) => String(s || "").trim().slice(0, 60);
21
+
22
+ // "af-ZA" / "afrikaans" / "" -> a tag ("af-ZA"), or the name as typed when it isn't a tag.
23
+ function normalise(l) {
24
+ const s = clean(l); if (!s) return "";
25
+ if (/^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$/i.test(s)) { try { return Intl.getCanonicalLocales(s.replace(/_/g, "-"))[0]; } catch { return s; } }
26
+ return s;
27
+ }
28
+ // What it's called, in English (for the AI) and in itself (for Settings).
29
+ function languageName(tag, inLang = "en") {
30
+ if (!/^[a-z]{2,3}(-|$)/i.test(tag)) return tag;
31
+ try { return new Intl.DisplayNames([inLang], { type: "language" }).of(tag) || tag; } catch { return tag; }
32
+ }
33
+ const isEnglish = (tag) => !tag || /^en(-|$)/i.test(tag) || /^english$/i.test(tag);
34
+
35
+ // { lang, auto, chosen, name, own, english }
36
+ function languageState(cfg = loadConfig()) {
37
+ const chosen = normalise(cfg.language), auto = normalise(cfg.languageAuto), lang = chosen || auto || "en";
38
+ return { lang, auto: auto || "en", chosen: chosen || "", name: languageName(lang), own: languageName(lang, /^[a-z]{2,3}(-|$)/i.test(lang) ? lang : "en"), english: isEnglish(lang) };
39
+ }
40
+ // The window says what the computer's set to; Settings can name another (or "" for the computer's).
41
+ function setLanguage({ auto, chosen } = {}) {
42
+ const cfg = loadConfig();
43
+ if (auto !== undefined) cfg.languageAuto = normalise(auto);
44
+ if (chosen !== undefined) { const c = normalise(chosen); if (c) cfg.language = c; else delete cfg.language; }
45
+ saveConfig(cfg);
46
+ return languageState(cfg);
47
+ }
48
+ // One line for an AI's instructions: "" in English.
49
+ function languageLine(st = languageState()) {
50
+ if (st.english) return "";
51
+ return `\n\nWrite in ${st.name}${st.name !== st.lang ? ` (${st.lang})` : ""}: that's the language they use Symbiot in. If they write to you in another language, answer in that one. Keep names, code, file names, commands, URLs and quoted text as they are.`;
52
+ }
53
+
54
+ // ---- the app's own words ----------------------------------------------------
55
+ const fileFor = (lang) => join(DIR(), lang.replace(/[^A-Za-z0-9_-]/g, "_") + ".json");
56
+ function cached(lang) { try { return JSON.parse(readFileSync(fileFor(lang), "utf8")); } catch { return {}; } }
57
+ function saveCache(lang, map) { mkdirSync(DIR(), { recursive: true }); writeFileSync(fileFor(lang), JSON.stringify(map)); }
58
+ const holes = (s) => (String(s).match(/\{#\}/g) || []).length;
59
+
60
+ const BUSY = new Map(); // lang -> the translation in progress, so two windows don't pay twice
61
+ // Translations for these strings: what's kept is returned at once; what's missing is
62
+ // translated (by the AI, in batches) and kept. ask is the AI (tests pass their own).
63
+ async function translate(lang, strings, { ask = write } = {}) {
64
+ const tag = normalise(lang);
65
+ if (isEnglish(tag)) return {};
66
+ const want = [...new Set((strings || []).map((s) => String(s)).filter((s) => s.trim() && s.length <= MAX_LEN))];
67
+ while (BUSY.has(tag)) { try { await BUSY.get(tag); } catch {} }
68
+ let map = cached(tag);
69
+ const missing = want.filter((s) => !(s in map));
70
+ if (missing.length) {
71
+ const job = (async () => {
72
+ const name = languageName(tag), parts = [];
73
+ for (let i = 0; i < missing.length; i += BATCH) parts.push(missing.slice(i, i + BATCH));
74
+ // up to 4 batches at a time: the first look at a new language takes about a minute, not several
75
+ const one = async (part) => {
76
+ const system = `You translate the interface of Symbiot, a desktop assistant app, from English into ${name}${name !== tag ? ` (${tag})` : ""}. These are its buttons, labels, headings and messages. Translate each one as a native ${name} app would say it: short, plain and natural, the same tone, the same capitalisation style. Keep exactly: every {#} (a number goes there), product and company names (Symbiot, Symbiot Pro, Claude, Claude Code, GitHub, Gmail, WhatsApp, LinkedIn, Ghost AI…), file names, code, commands, keyboard keys, emails and URLs. Answer with only a JSON array of the translations, in the same order, one for each.`;
77
+ let out = null;
78
+ try {
79
+ const raw = String(await ask(system, JSON.stringify(part), { fast: true }) || "");
80
+ const a = raw.indexOf("["), b = raw.lastIndexOf("]");
81
+ out = JSON.parse(raw.slice(a, b + 1));
82
+ } catch {}
83
+ // the AI didn't answer properly: those stay English, and are asked for again next time
84
+ if (!Array.isArray(out) || out.length !== part.length) return;
85
+ const kept = { ...cached(tag) };
86
+ part.forEach((src, k) => { const t = typeof out[k] === "string" ? out[k].trim() : ""; if (t && holes(t) === holes(src)) kept[src] = t; });
87
+ saveCache(tag, kept);
88
+ };
89
+ for (let i = 0; i < parts.length; i += PARALLEL) await Promise.all(parts.slice(i, i + PARALLEL).map(one));
90
+ })();
91
+ BUSY.set(tag, job);
92
+ try { await job; } finally { BUSY.delete(tag); }
93
+ map = cached(tag);
94
+ }
95
+ const res = {}; for (const s of want) if (s in map) res[s] = map[s];
96
+ return res;
97
+ }
98
+ function allTranslations(lang) { const tag = normalise(lang); return isEnglish(tag) ? {} : cached(tag); }
99
+ function forgetTranslations(lang) { const f = fileFor(normalise(lang)); if (existsSync(f)) writeFileSync(f, "{}"); return true; }
100
+
101
+ export { languageState, setLanguage, languageLine, languageName, normalise, isEnglish, translate, allTranslations, forgetTranslations };
package/links.mjs CHANGED
@@ -89,7 +89,7 @@ function stateOf(e, l, watches) {
89
89
  // finished signing in in the window is still "sign in"
90
90
  if (w.error && /^Signed out/.test(w.error)) return w.checked ? { state: "signedout", note: "Signed out: click it to sign in again." } : { state: "signin" };
91
91
  if (w.checked) return { state: "ok", checked: w.checked, ...(w.error ? { note: w.error } : {}) };
92
- return { state: "signin", ...(w.error && !/in use|busy|already running/i.test(w.error) ? { note: w.error } : {}) };
92
+ return { state: "signin", ...(w.error && !/in use|busy|already running|Symbiot Browser is open/i.test(w.error) ? { note: w.error } : {}) };
93
93
  }
94
94
  function linksState() {
95
95
  const c = catalog(), l = linked(), watches = watchState().watches;
@@ -98,7 +98,7 @@ function linksState() {
98
98
  return { items, groups, linked: items.filter((x) => x.state !== "off").length, ...(c.file ? { file: c.file } : {}), ...(c.error ? { error: c.error } : {}) };
99
99
  }
100
100
 
101
- // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
101
+ // Link one: trust its hosts, watch its page, open it to sign in (in the Symbiot Browser). Clicking a link
102
102
  // that's already there just opens it again to sign in (signed out, or a second
103
103
  // account). Gives { ok, url, item } or { error }.
104
104
  // what counts as an inbox or chat for Free's one
package/mind.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  // signed in to your linked sites); "task" adds it to your list for later or for a
17
17
  // repo. Anything hard to undo (closing an account, deleting, paying, sending) the
18
18
  // agent asks you about first, in the Agents tab: the brief says so.
19
+ import { languageLine } from "./lang.mjs";
19
20
  import { join } from "node:path";
20
21
  import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
21
22
  import { fileURLToPath } from "node:url";
@@ -293,7 +294,7 @@ async function converse({ where, role = "", context = "", history = "", question
293
294
  let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
294
295
  // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
295
296
  const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
296
- const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes), nav)}${voice ? "\n" + voice : ""}`;
297
+ const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes), nav)}${voice ? "\n" + voice : ""}${languageLine()}`;
297
298
  const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app (other chats: a short answer here doesn't reply to these):\n${elsewhere}\n\n` : "") +
298
299
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
299
300
  const raw = await ask(system, prompt, images.length ? { images } : undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,9 @@
53
53
  "icon.ico",
54
54
  "claudesetup.mjs",
55
55
  "voice.mjs",
56
- "licence.mjs"
56
+ "licence.mjs",
57
+ "lang.mjs",
58
+ "browser.html"
57
59
  ],
58
60
  "engines": {
59
61
  "node": ">=18"
@@ -86,7 +88,7 @@
86
88
  "@anthropic-ai/sdk": "^0.128.0"
87
89
  },
88
90
  "scripts": {
89
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
91
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
90
92
  "lint": "eslint .",
91
93
  "postinstall": "node postinstall.mjs"
92
94
  },
package/server.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // (ui.mjs) and its /api routes, each a thin call into the module that does the
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
+ import { languageState, setLanguage, translate, allTranslations } from "./lang.mjs";
5
6
  import { licenceState, setKey, clearKey, can, refreshRevoked, FREE } from "./licence.mjs";
6
7
  import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
7
8
  import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
@@ -24,7 +25,7 @@ import { computeDrift } from "./drift.mjs";
24
25
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
25
26
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
26
27
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
27
- import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
28
+ import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub, siteUrl } from "./headless.mjs";
28
29
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
29
30
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
30
31
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
@@ -223,6 +224,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
223
224
  if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
224
225
  const p = await askRunningApp("ping", { port: PORT, token: TOKEN });
225
226
  if (p && p.version) {
227
+ setBrowserHub(`http://127.0.0.1:${PORT}/browser?t=${TOKEN}`);
226
228
  const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
227
229
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
228
230
  console.log(how ? c.d(` Opened the existing window (${/^[aeiou]/.test(how) ? "an" : "a"} ${how}).`) : c.d(" Open that URL in your browser."));
@@ -250,6 +252,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
250
252
  const t0 = Date.now(); res.on("finish", () => { const ms = Date.now() - t0; if (ms > 1500) console.log(`slow: ${String(req.url || "").split("?")[0]} took ${(ms / 1000).toFixed(1)}s`); });
251
253
  const u = new URL(req.url, "http://127.0.0.1");
252
254
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
255
+ // the Symbiot Browser's own page (headless.mjs openSymbiotBrowser): like /, it gets its token in ?t=
256
+ if (req.method === "GET" && u.pathname === "/browser") { try { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(readFileSync(new URL("./browser.html", import.meta.url))); } catch { res.writeHead(404); res.end(); } return; }
253
257
  // the app's typeface ships in the package (fonts/), so it's there offline
254
258
  // the window's own icon (its taskbar entry), the same orb as the app menu's
255
259
  if (req.method === "GET" && (u.pathname === "/favicon.svg" || u.pathname === "/favicon.ico")) {
@@ -377,7 +381,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
377
381
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
378
382
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
379
383
  if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
380
- if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
384
+ if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""), b.here ? { open: async (url) => ({ ok: true, url }) } : undefined)); } // here: from the Symbiot Browser, which opens the site itself
385
+ if (u.pathname === "/api/browser/open" && req.method === "POST") { const b = await readBody(req); if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { ok: false, note: "not opened (SYMBIOT_NO_OPEN)" }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
386
+ if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); setTimeout(() => { for (const it of linksState().items) if (it.state !== "off" && it.state !== "ok") checkLink(it.id).catch(() => {}); }, 1500); return json(res, { ...r, checking: true }); }
381
387
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
382
388
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
383
389
  // Posts (post.mjs): the week's drafts, waiting on you. Approve is your yes: Marketing's
@@ -515,6 +521,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
515
521
  if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
516
522
  if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
517
523
  if (u.pathname === "/api/claude/code" && req.method === "POST") { const b = await readBody(req); return json(res, sendClaudeCode(b.code)); }
524
+ // the language (lang.mjs): the window reports the computer's, Settings can name any; the app's own words, translated once and kept
525
+ if (u.pathname === "/api/language/set" && req.method === "POST") { const b = await readBody(req); return json(res, setLanguage({ ...(b.auto !== undefined ? { auto: b.auto } : {}), ...(b.chosen !== undefined ? { chosen: b.chosen } : {}) })); }
526
+ if (u.pathname === "/api/language") return json(res, languageState());
527
+ if (u.pathname === "/api/i18n/all") return json(res, { map: allTranslations(u.searchParams.get("lang") || languageState().lang) });
528
+ if (u.pathname === "/api/i18n" && req.method === "POST") { const b = await readBody(req); return json(res, { map: await translate(b.lang || "", Array.isArray(b.strings) ? b.strings.slice(0, 400) : []) }); }
518
529
  // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, a Free project slot freed
519
530
  if (u.pathname === "/api/licence") return json(res, licenceView());
520
531
  if (u.pathname === "/api/licence/key" && req.method === "POST") { const b = await readBody(req); const r = setKey(b.key); return json(res, r.error ? r : licenceView()); }
package/ui.mjs CHANGED
@@ -802,6 +802,7 @@ body.lq-liquid #panel-settings{max-width:780px;margin:0 auto;display:flex;flex-d
802
802
  body.lq-liquid .sset{border:1px solid rgba(255,255,255,.07);background:rgba(255,255,255,.03);border-radius:18px;padding:18px 20px}
803
803
  body.lq-liquid.lq-light .sset{border-color:rgba(21,26,33,.07);background:rgba(255,255,255,.55)}
804
804
  body.lq-liquid .sset.hidden,body.lq-liquid #panel-settings.hidden{display:none}
805
+ #i18nnote{position:fixed;left:16px;bottom:16px;z-index:60;max-width:calc(100vw - 32px);padding:8px 14px;border-radius:999px;font-size:13px;background:rgba(10,11,14,.88);color:#ECE9E4;border:1px solid rgba(220,228,240,.18);opacity:0;transition:opacity .4s;pointer-events:none}#i18nnote.on{opacity:1}body.lq-light #i18nnote{background:rgba(255,255,255,.94);color:#151A21;border-color:rgba(20,30,45,.15)}
805
806
  .licproj{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}.licproj .chip{display:inline-flex;align-items:center;gap:8px;padding:4px 6px 4px 12px;border:1px solid var(--line);border-radius:999px;font-size:13px}.licproj .chip button{padding:2px 8px;font-size:12px}
806
807
  .ssh{margin:0 0 10px;font:600 16px var(--sans);letter-spacing:-.01em;color:var(--bone);display:flex;align-items:baseline;gap:8px}
807
808
  .ssh .muted{font-weight:400;font-size:12.5px}
@@ -1169,6 +1170,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1169
1170
  </section>
1170
1171
  <section id="panel-settings" class="hidden">
1171
1172
  <div id="firststeps" class="sset hidden" aria-live="polite"></div>
1173
+ <div class="sset" id="langset"><h3 class="ssh">Language</h3><div id="langbox"><span class="muted">…</span></div></div>
1172
1174
  <div class="sset" id="licset"><h3 class="ssh">Symbiot Pro</h3><div id="licbox"><span class="muted">Checking…</span></div></div>
1173
1175
  <div class="sset"><h3 class="ssh">Setup</h3><div class="row"><span class="muted" style="flex:1">Walk through setup again: your AI, your work, your agent and your apps.</span><button class="ghost" id="rerunsetup">Run setup again</button></div></div>
1174
1176
  <div class="sset"><h3 class="ssh">Your AI</h3>
@@ -1224,6 +1226,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1224
1226
  </div>
1225
1227
  </div>
1226
1228
  <div class="sset">
1229
+ <div class="row" id="sbrow" style="margin-bottom:12px"><span style="flex:1"><b>The Symbiot Browser</b><br><span class="muted">Symbiot’s own browser window, where you sign in to your sites once so Symbiot can read them for you.</span></span><button class="act" id="sbopen">Open the Symbiot Browser</button></div>
1227
1230
  <div id="links"></div>
1228
1231
  <div id="linksmsg"></div>
1229
1232
  <div class="note muted" id="linksnote">For a whole team, one <b>links.json</b> in Symbiot's config folder adds your company's own sites and hides the ones you don't use, so everyone gets the same buttons.</div>
@@ -1313,7 +1316,7 @@ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==
1313
1316
  $('panel-map').classList.toggle('hidden',!isMap);
1314
1317
  $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
1315
1318
  $('panel-run').classList.toggle('hidden',!isRun);
1316
- $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();}
1319
+ $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();langLoad();}
1317
1320
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1318
1321
  $('panel-drift').classList.toggle('hidden',!isDrift);
1319
1322
  $('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
@@ -1330,6 +1333,53 @@ lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
1330
1333
  // takes you to its part, lit up. The block goes once all are done.
1331
1334
  var FSKIP='symbiot-skip-company';
1332
1335
  function fsSkipped(){try{return !!(window.localStorage&&window.localStorage.getItem(FSKIP));}catch(e){return false;}}
1336
+ // The app in your language (lang.mjs). Its own words on screen are swapped for their
1337
+ // translation (your AI makes each once; they're kept, so it's instant after that).
1338
+ // Numbers stay out of what's translated: "3 at work" is "{#} at work". Your content
1339
+ // (tasks, chats, drafts, write-ups, names, code, anything typed) is never touched,
1340
+ // and new things are translated as they appear.
1341
+ var I18N={on:false,lang:'',map:{},want:{},tried:{},timer:0,busy:false,orig:new WeakMap(),attrs:new WeakMap(),dirty:[],raf:0,obs:null};
1342
+ var I18N_SKIP="[data-nt],.t,.tfull,.lqmsg,.dmsg,.wkfinal,.wkraw,.rname,.btf,#lqtalk,code,pre,kbd,textarea,script,style,svg";
1343
+ var I18N_W=String.fromCharCode(32,9,10,13,160);
1344
+ var I18N_WS=new RegExp('['+I18N_W+']+','g'),I18N_EDGE=new RegExp('^(['+I18N_W+']*)([^]*?)(['+I18N_W+']*)$');
1345
+ var I18N_NUM=/[0-9]+([.,][0-9]+)?/g;
1346
+ function i18nKey(s){return String(s).replace(I18N_WS,' ').trim().replace(I18N_NUM,'{#}');}
1347
+ function i18nOk(k){if(!k||k.length>400||!/[A-Za-z]/.test(k))return false;if(/@|:[/][/]|[/][A-Za-z0-9._-]+[/]/.test(k))return false;if(/^[a-z0-9._-]+$/.test(k)&&/[._0-9-]/.test(k))return false;return true;}
1348
+ function i18nFill(src,tr){var nums=String(src).match(I18N_NUM)||[],i=0;return tr.replace(/[{]#[}]/g,function(){return nums[i++]||'';});}
1349
+ function i18nOne(v){var m=String(v).match(I18N_EDGE),core=m?m[2]:String(v),k=i18nKey(core);if(!i18nOk(k))return null;var tr=I18N.map[k];
1350
+ if(tr==null){if(!I18N.tried[k]){I18N.want[k]=1;i18nLater();}return null;}return (m?m[1]:'')+i18nFill(core,tr)+(m?m[3]:'');}
1351
+ function i18nSkip(el){return !el||!!(el.closest&&el.closest(I18N_SKIP))||!!el.isContentEditable;}
1352
+ function i18nText(n){var o=I18N.orig.get(n),v=n.nodeValue,src=o&&v===o.tr?o.src:v,out=i18nOne(src);if(out!=null&&out!==v){I18N.orig.set(n,{src:src,tr:out});n.nodeValue=out;}}
1353
+ function i18nAttrs(el){if(/^(TEXTAREA|INPUT)$/.test(el.tagName||"")?i18nSkip(el.parentElement):i18nSkip(el))return;['placeholder','title','aria-label'].forEach(function(a){if(!el.hasAttribute||!el.hasAttribute(a))return;var rec=I18N.attrs.get(el)||{},cur=el.getAttribute(a),src=rec[a]&&cur===rec[a].tr?rec[a].src:cur,out=i18nOne(src);if(out!=null&&out!==cur){rec[a]={src:src,tr:out};I18N.attrs.set(el,rec);el.setAttribute(a,out);}});}
1354
+ function i18nWalk(root){if(!I18N.on||!root)return;
1355
+ if(root.nodeType===3){if(!i18nSkip(root.parentElement))i18nText(root);return;}
1356
+ if(root.nodeType!==1||!root.isConnected)return;i18nAttrs(root);if(i18nSkip(root))return;root.querySelectorAll('[placeholder],[title],[aria-label]').forEach(i18nAttrs);
1357
+ var w=document.createTreeWalker(root,5,{acceptNode:function(n){if(n.nodeType===1)return (n.matches(I18N_SKIP)||n.isContentEditable)?2:3;return 1;}}),t;
1358
+ while((t=w.nextNode()))i18nText(t);}
1359
+ function i18nLater(){if(I18N.timer)return;I18N.timer=setTimeout(i18nFlush,350);}
1360
+ function i18nFlush(){I18N.timer=0;if(I18N.busy){i18nLater();return;}var ks=Object.keys(I18N.want).slice(0,400);if(!ks.length)return;
1361
+ ks.forEach(function(k){delete I18N.want[k];I18N.tried[k]=1;});I18N.busy=true;i18nNote(true);
1362
+ api('/api/i18n',{lang:I18N.lang,strings:ks}).then(function(r){var m=(r&&r.map)||{};for(var k in m)I18N.map[k]=m[k];I18N.busy=false;i18nWalk(document.body);if(Object.keys(I18N.want).length)i18nLater();else i18nNote(false);}).catch(function(){I18N.busy=false;i18nNote(false);});}
1363
+ // the first time in a language there's a lot to translate: say so, quietly, until it's done
1364
+ function i18nNote(on){var el=$('i18nnote');if(on&&Object.keys(I18N.map).length<150){if(!el){el=document.createElement('div');el.id='i18nnote';el.setAttribute('data-nt','');el.setAttribute('role','status');document.body.appendChild(el);}el.textContent='Translating Symbiot into '+((LANG&&(LANG.own||LANG.name))||I18N.lang)+'… the first time takes a minute.';el.className='on';}else if(!on&&el){el.className='';}}
1365
+ function i18nWatch(){if(I18N.obs||typeof MutationObserver!=='function')return;
1366
+ I18N.obs=new MutationObserver(function(ms){ms.forEach(function(m){if(m.type==='childList')m.addedNodes.forEach(function(n){I18N.dirty.push(n);});else I18N.dirty.push(m.target);});
1367
+ if(!I18N.raf)I18N.raf=requestAnimationFrame(function(){I18N.raf=0;var d=I18N.dirty;I18N.dirty=[];d.forEach(function(n){i18nWalk(n);});});});
1368
+ I18N.obs.observe(document.body,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['placeholder','title','aria-label']});}
1369
+ function i18nStart(){api('/api/language/set',{auto:(navigator.languages&&navigator.languages[0])||navigator.language||''}).then(function(st){if(!st||!st.lang)return;I18N.lang=st.lang;LANG=st;
1370
+ try{document.documentElement.lang=st.lang;}catch(e){}
1371
+ if(st.english)return;I18N.on=true;
1372
+ api('/api/i18n/all?lang='+encodeURIComponent(st.lang)).then(function(r){I18N.map=(r&&r.map)||{};i18nWalk(document.body);i18nWatch();}).catch(function(){i18nWatch();});}).catch(function(){});}
1373
+ // Settings: the language, your computer's or any you name
1374
+ var LANG=null;
1375
+ function langLoad(){api('/api/language').then(langRender).catch(function(){});}
1376
+ function langRender(st){var el=$('langbox');if(!el||!st)return;LANG=st;
1377
+ el.innerHTML="<p>Symbiot is in <b data-nt>"+esc(st.own||st.name)+"</b>"+(st.chosen?'.':', the language your computer uses.')+"</p>"+
1378
+ "<div class='row' style='margin-top:8px'><input id='langin' placeholder='Any language: Afrikaans, isiZulu, Português, 日本語…' style='flex:1'><button class='act' id='languse'>Use it</button>"+(st.chosen?"<button class='ghost' id='langauto'>Use my computer’s language</button>":"")+"</div>"+
1379
+ "<p class='muted' style='margin-top:8px'>Symbiot writes to you in it, and its own words are translated by your AI the first time you see them, then kept.</p>";
1380
+ var go=function(v){api('/api/language/set',{chosen:v}).then(function(){location.reload();}).catch(function(){});};
1381
+ var u=$('languse');if(u)u.addEventListener('click',function(){var v=($('langin').value||'').trim();if(v)go(v);});
1382
+ var a=$('langauto');if(a)a.addEventListener('click',function(){go('');});}
1333
1383
  // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, Free's 3 projects.
1334
1384
  function licDate(ms){try{return new Date(ms).toLocaleDateString(undefined,{day:'numeric',month:'long',year:'numeric'});}catch(e){return '';}}
1335
1385
  function licLoad(){api('/api/licence').then(licRender).catch(function(){});}
@@ -1959,15 +2009,16 @@ var rc=reachOf(d,it.id),rt=rc?(rc.ready?" Your agent's runs can use it too, thro
1959
2009
  return "<div class='lnk cx "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button type='button' class='lbtn' title='"+escQ(String(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it')).replace(/\.?\s*$/,rt?'.':'')+rt)+"'><span class='cxb' aria-hidden='true'></span><span class='cxn'>"+esc(it.name)+"</span>"+(rc?"<i class='ic-plug cxa"+(rc.ready?" ready":"")+"' role='img' aria-label='"+escQ(rc.ready?"agent runs can use it too":"agent runs can use it once connected in claude.ai")+"'></i>":"")+"<span class='cxs'>"+(on?esc(LINKWORD[it.state]||''):'Link')+"</span></button>"+
1960
2010
  (on?"<button type='button' class='lmore' title='check it now' aria-label='Check "+escQ(it.name)+" now'>&#8635;</button><button type='button' class='lrm' title='unlink: stop watching it and stop trusting it'>Unlink</button>":"")+"</div>";}).join('')+"</div>";}).join('')+"</div>";return h;}
1961
2011
  function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
2012
+ function sbOpen(url){api('/api/browser/open',url?{url:url}:{}).then(function(r){if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");}).catch(function(){});}
1962
2013
  function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
1963
2014
  el.innerHTML=linksHtml(LINKS,id==='boardlinks');
1964
2015
  el.querySelectorAll('.lnk').forEach(function(sp){var lid=sp.getAttribute('data-id'),nm=sp.querySelector('.cxn'),name=nm?nm.textContent:sp.querySelector('.lbtn').textContent;
1965
2016
  sp.querySelector('.lbtn').addEventListener('click',function(){linksOut("<div class='note muted'>Opening "+esc(name)+"&hellip;</div>");
1966
2017
  api('/api/links/link',{id:lid}).then(function(r){if(!r||r.error){linksOut("<div class='note err'>"+esc(name)+": "+esc((r&&r.error)||'failed')+"</div>");loadLinks();return;}
1967
- linksOut("<div class='note ok'>Opened "+esc(name)+" in Symbiot's browser. Sign in there as you normally do, then close that window: Symbiot checks it within a minute.</div>");loadLinks();
2018
+ linksOut("<div class='note ok'>Opened the Symbiot Browser with "+esc(name)+". Sign in there as you normally do, then click Done in the Symbiot Browser: Symbiot checks it within a minute.</div>");loadLinks();
1968
2019
  [20000,60000,120000].forEach(function(ms){setTimeout(loadLinks,ms);});});});
1969
2020
  var m=sp.querySelector('.lmore');if(m)m.addEventListener('click',function(){m.disabled=true;api('/api/links/check',{id:lid}).then(function(r){m.disabled=false;
1970
- if(r&&r.busy)linksOut("<div class='note muted'>Symbiot's browser is busy (a sign-in window is open?). Close it, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
2021
+ if(r&&r.busy)linksOut("<div class='note muted'>The Symbiot Browser is open. Click Done in it when you’ve signed in, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
1971
2022
  var x=sp.querySelector('.lrm');if(x)x.addEventListener('click',function(){api('/api/links/unlink',{id:lid}).then(function(){linksOut('');loadLinks();loadTrusted();loadBoard();});});});});}
1972
2023
  // What Symbiot remembers (mind.mjs): a row per thing, with what it knows; × forgets one.
1973
2024
  function loadMind(){api('/api/mind').then(function(d){var el=document.getElementById('mindlist');if(!el||!d)return;var ns=d.nodes||[];
@@ -2134,7 +2185,7 @@ var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It open
2134
2185
  var OPEN_WA_TIP="open WhatsApp in Symbiot&#39;s browser, where the chat shows your agent&#39;s reply in its message box, for you to read and send";
2135
2186
  function openChatUI(n,b,say){if(!n)return;b.disabled=true;
2136
2187
  api('/api/watch/open-chat',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){say('err',esc((x&&x.error)||'failed'));return;}
2137
- say('ok',"&#10003; Opened WhatsApp in Symbiot's browser, in a window of its own. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
2188
+ say('ok',"&#10003; Opened WhatsApp in the Symbiot Browser. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
2138
2189
  var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
2139
2190
  // a LinkedIn comment or mention (watch.mjs socialBrief): typed into the comment box, never posted
2140
2191
  var DRAFT_SOCIAL_TIP="your coding agent opens it on LinkedIn in Symbiot&#39;s hidden browser and types a reply into the comment box, unposted. It never presses Post, Comment, Reply or Send";
@@ -3464,7 +3515,7 @@ b.querySelectorAll('[data-check]').forEach(function(x){x.addEventListener('click
3464
3515
  b.querySelectorAll('[data-skip]').forEach(function(x){x.addEventListener('click',function(){api('/api/onboarding/set',{skip:x.getAttribute('data-skip')}).then(function(r){ONB=r;onbRender();});});});
3465
3516
  b.querySelectorAll('[data-unskip]').forEach(function(x){x.addEventListener('click',function(){api('/api/onboarding/set',{unskip:x.getAttribute('data-unskip')}).then(function(r){ONB=r;onbRender();});});});}
3466
3517
  function lqIcon(l){var k=document.querySelector('link[rel=icon]');if(k)k.setAttribute('href','/favicon.svg?look='+encodeURIComponent(l||'ferro'));}
3467
- function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqIcon(lqLookGet());lqTheme();bd.classList.add('lq-liquid');
3518
+ function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqIcon(lqLookGet());lqTheme();try{i18nStart();}catch(e){}bd.classList.add('lq-liquid');
3468
3519
  ['(prefers-color-scheme: light)','(prefers-contrast: more)','(forced-colors: active)','(prefers-reduced-transparency: reduce)','(prefers-reduced-motion: reduce)'].forEach(function(q){try{var mq=window.matchMedia&&window.matchMedia(q);if(mq&&mq.addEventListener)mq.addEventListener('change',lqTheme);else if(mq&&mq.addListener)mq.addListener(lqTheme);}catch(e){}});
3469
3520
  if(document.addEventListener){document.addEventListener('contextmenu',function(ev){var b=document.body,t=ev&&ev.target;if(!b||!b.classList||!b.classList.contains('lq-liquid'))return;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable]'))return;var sel=window.getSelection?String(window.getSelection()):'';if(sel)return;ev.preventDefault();lqBack();});
3470
3521
  document.addEventListener('mouseup',function(ev){var b=document.body;if(ev&&ev.button===3&&b&&b.classList&&b.classList.contains('lq-liquid')){ev.preventDefault();lqBack();}});}
@@ -3494,5 +3545,5 @@ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAn
3494
3545
  function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0)lqStep();var every=(GLSOFT||GLSTILL)?glEvery(LQ.mode==='aware'):LQ.mode==='pool'?6:LQ.mode==='rest'?2:1;if(G&&LQ.frame%every===0)lqDraw(G);}raf(frame);} // moves every frame; drawn less at rest, behind a panel, without a GPU
3495
3546
  raf(frame);}
3496
3547
 
3497
- initGraphEvents();syncP();refresh();firstTab();lqInit();onbLoad();var rr=$('rerunsetup');if(rr)rr.addEventListener('click',function(){api('/api/onboarding/restart',{}).then(function(){lqSink();onbLoad();});});awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3548
+ initGraphEvents();syncP();refresh();firstTab();lqInit();onbLoad();var sbo=$('sbopen');if(sbo)sbo.addEventListener('click',function(){sbOpen('');});var rr=$('rerunsetup');if(rr)rr.addEventListener('click',function(){api('/api/onboarding/restart',{}).then(function(){lqSink();onbLoad();});});awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3498
3549
  </script></body></html>`;
package/writeups.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // What Symbiot writes with your AI: your week, standup and to-dos, a repo's
2
2
  // review and next steps, a folder's overview, and the Q&A on a task. Plus the
3
3
  // opt-in sent mail that week and standup fold in (read by mail.mjs).
4
+ import { languageLine } from "./lang.mjs";
4
5
  import { join } from "node:path";
5
6
  import { readFileSync, existsSync } from "node:fs";
6
7
  import { VERSION, loadConfig, saveConfig, loadTasks, saveTasks, sh, repoState } from "./core.mjs";
@@ -50,7 +51,7 @@ async function repoReview(path) {
50
51
  (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
51
52
  (state.stale ? `Working tree: STALE (≈ ${state.staleBy ? "HEAD~" + state.staleBy : "older"}); its "changes" are the gap to an old snapshot, not new work.\n\n` : "") +
52
53
  `Return the JSON. Remember: fewer, higher-value ideas beat a long list; a "don't add — stabilise" verdict with 0–2 ideas is a valid answer.`;
53
- const raw = await write(system, prompt);
54
+ const raw = await write(system + languageLine(), prompt);
54
55
  if (!raw) return { text: "(couldn't reach the model)", ideas: [], verdict: "", footer };
55
56
  const j = extractJson(raw);
56
57
  if (j && (j.review || j.ideas || j.verdict)) return {
@@ -94,7 +95,7 @@ async function repoSuggest(path) {
94
95
  `Project: ${name} (branch ${state.branch}, ${auth.total} commits, ${auth.mineCount} yours${auth.filterDropped ? " — filter dropped" : ""}).\n\n` +
95
96
  `Recent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\n` +
96
97
  (rules ? `Conventions — do NOT advise against these:\n${rules}\n\n` : "") + `Give the advice.`;
97
- const text = await write(system, prompt);
98
+ const text = await write(system + languageLine(), prompt);
98
99
  return { text: text || "(couldn't reach the model)", footer };
99
100
  }
100
101
  // Overview + suggestions for a NON-git project folder (no commits to read), from
@@ -117,7 +118,7 @@ async function folderSuggest(path) {
117
118
  const prompt =
118
119
  `Folder: ${det.name}\nStack: ${stack}\nFiles (${det.files}):\n${rels.slice(0, 120).join("\n")}\n\n` +
119
120
  (manifest ? `Manifest:\n${manifest}\n\n` : "") + (readme ? `README excerpt:\n${readme}\n\n` : "") + `Write the overview and suggestions.`;
120
- const text = await write(system, prompt);
121
+ const text = await write(system + languageLine(), prompt);
121
122
  const footer = `symbiot ${VERSION} · folder · ${det.files} files · ${stack} · not a git repo`;
122
123
  return { text: text || "(couldn't reach the model)", footer };
123
124
  }
@@ -228,7 +229,7 @@ async function produce(cmd, { since = 7, all: everyone = false } = {}) {
228
229
  `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
229
230
  `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
230
231
  `Be concise and concrete. No preamble.`;
231
- const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
232
+ const text = await write(system + languageLine(), `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
232
233
  return { text: text || "(couldn't reach the model)", sub: `${open.length} open items · todo`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${open.length} open items` };
233
234
  }
234
235
  const label = cmd === "standup" ? "standup" : "week";
@@ -265,7 +266,7 @@ async function produce(cmd, { since = 7, all: everyone = false } = {}) {
265
266
  (waiting.length ? `Waiting on me (new since yesterday):\n${waiting.map((g) => `- ${g.label}:\n${g.items.slice(0, 10).map((t) => ` - ${t.slice(0, 160)}`).join("\n")}`).join("\n")}\n\n` : "") +
266
267
  `Write the ${label === "standup" ? "standup" : "update"}.`;
267
268
 
268
- const text = await write(system, prompt);
269
+ const text = await write(system + languageLine(), prompt);
269
270
  const mailNote = (mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "") + (arrivedN ? ` · ${arrivedN} from linked sites` : "");
270
271
  return { text: text ? withWaiting(text, line) : "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote}${waitNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
271
272
  }