symbiot 0.59.0 → 0.60.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,11 @@
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.60.0
6
+
7
+ - **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).
8
+ - **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.
9
+
5
10
  ## 0.59.0
6
11
 
7
12
  - **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,16 @@ 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
+ ## Any language
1423
+
1424
+ Symbiot uses the language your computer is set to, or any language you name in
1425
+ **Settings → Language** ("Afrikaans", "isiZulu", "pt-BR", anything). Chats, your week,
1426
+ standups and to-dos are written in it (a chat answers in whatever language you write to
1427
+ it in), and the app's own words are translated by your AI the first time you see them
1428
+ in that language (about half a minute, with your AI's fastest model), then kept in
1429
+ `~/.config/symbiot/i18n/`, so it's instant after that. Your own content (tasks, chats,
1430
+ drafts, write-ups, names, code) is never touched.
1431
+
1422
1432
  ## Symbiot Free and Pro
1423
1433
 
1424
1434
  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/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/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.60.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,8 @@
53
53
  "icon.ico",
54
54
  "claudesetup.mjs",
55
55
  "voice.mjs",
56
- "licence.mjs"
56
+ "licence.mjs",
57
+ "lang.mjs"
57
58
  ],
58
59
  "engines": {
59
60
  "node": ">=18"
@@ -86,7 +87,7 @@
86
87
  "@anthropic-ai/sdk": "^0.128.0"
87
88
  },
88
89
  "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",
90
+ "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/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
91
  "lint": "eslint .",
91
92
  "postinstall": "node postinstall.mjs"
92
93
  },
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";
@@ -515,6 +516,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
515
516
  if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
516
517
  if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
517
518
  if (u.pathname === "/api/claude/code" && req.method === "POST") { const b = await readBody(req); return json(res, sendClaudeCode(b.code)); }
519
+ // the language (lang.mjs): the window reports the computer's, Settings can name any; the app's own words, translated once and kept
520
+ 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 } : {}) })); }
521
+ if (u.pathname === "/api/language") return json(res, languageState());
522
+ if (u.pathname === "/api/i18n/all") return json(res, { map: allTranslations(u.searchParams.get("lang") || languageState().lang) });
523
+ 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
524
  // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, a Free project slot freed
519
525
  if (u.pathname === "/api/licence") return json(res, licenceView());
520
526
  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>
@@ -1313,7 +1315,7 @@ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==
1313
1315
  $('panel-map').classList.toggle('hidden',!isMap);
1314
1316
  $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
1315
1317
  $('panel-run').classList.toggle('hidden',!isRun);
1316
- $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();}
1318
+ $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();langLoad();}
1317
1319
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1318
1320
  $('panel-drift').classList.toggle('hidden',!isDrift);
1319
1321
  $('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
@@ -1330,6 +1332,53 @@ lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
1330
1332
  // takes you to its part, lit up. The block goes once all are done.
1331
1333
  var FSKIP='symbiot-skip-company';
1332
1334
  function fsSkipped(){try{return !!(window.localStorage&&window.localStorage.getItem(FSKIP));}catch(e){return false;}}
1335
+ // The app in your language (lang.mjs). Its own words on screen are swapped for their
1336
+ // translation (your AI makes each once; they're kept, so it's instant after that).
1337
+ // Numbers stay out of what's translated: "3 at work" is "{#} at work". Your content
1338
+ // (tasks, chats, drafts, write-ups, names, code, anything typed) is never touched,
1339
+ // and new things are translated as they appear.
1340
+ var I18N={on:false,lang:'',map:{},want:{},tried:{},timer:0,busy:false,orig:new WeakMap(),attrs:new WeakMap(),dirty:[],raf:0,obs:null};
1341
+ var I18N_SKIP="[data-nt],.t,.tfull,.lqmsg,.dmsg,.wkfinal,.wkraw,.rname,.btf,#lqtalk,code,pre,kbd,textarea,script,style,svg";
1342
+ var I18N_W=String.fromCharCode(32,9,10,13,160);
1343
+ var I18N_WS=new RegExp('['+I18N_W+']+','g'),I18N_EDGE=new RegExp('^(['+I18N_W+']*)([^]*?)(['+I18N_W+']*)$');
1344
+ var I18N_NUM=/[0-9]+([.,][0-9]+)?/g;
1345
+ function i18nKey(s){return String(s).replace(I18N_WS,' ').trim().replace(I18N_NUM,'{#}');}
1346
+ 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;}
1347
+ function i18nFill(src,tr){var nums=String(src).match(I18N_NUM)||[],i=0;return tr.replace(/[{]#[}]/g,function(){return nums[i++]||'';});}
1348
+ 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];
1349
+ if(tr==null){if(!I18N.tried[k]){I18N.want[k]=1;i18nLater();}return null;}return (m?m[1]:'')+i18nFill(core,tr)+(m?m[3]:'');}
1350
+ function i18nSkip(el){return !el||!!(el.closest&&el.closest(I18N_SKIP))||!!el.isContentEditable;}
1351
+ 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;}}
1352
+ 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);}});}
1353
+ function i18nWalk(root){if(!I18N.on||!root)return;
1354
+ if(root.nodeType===3){if(!i18nSkip(root.parentElement))i18nText(root);return;}
1355
+ if(root.nodeType!==1||!root.isConnected)return;i18nAttrs(root);if(i18nSkip(root))return;root.querySelectorAll('[placeholder],[title],[aria-label]').forEach(i18nAttrs);
1356
+ 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;
1357
+ while((t=w.nextNode()))i18nText(t);}
1358
+ function i18nLater(){if(I18N.timer)return;I18N.timer=setTimeout(i18nFlush,350);}
1359
+ function i18nFlush(){I18N.timer=0;if(I18N.busy){i18nLater();return;}var ks=Object.keys(I18N.want).slice(0,400);if(!ks.length)return;
1360
+ ks.forEach(function(k){delete I18N.want[k];I18N.tried[k]=1;});I18N.busy=true;i18nNote(true);
1361
+ 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);});}
1362
+ // the first time in a language there's a lot to translate: say so, quietly, until it's done
1363
+ 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='';}}
1364
+ function i18nWatch(){if(I18N.obs||typeof MutationObserver!=='function')return;
1365
+ 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);});
1366
+ if(!I18N.raf)I18N.raf=requestAnimationFrame(function(){I18N.raf=0;var d=I18N.dirty;I18N.dirty=[];d.forEach(function(n){i18nWalk(n);});});});
1367
+ I18N.obs.observe(document.body,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['placeholder','title','aria-label']});}
1368
+ 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;
1369
+ try{document.documentElement.lang=st.lang;}catch(e){}
1370
+ if(st.english)return;I18N.on=true;
1371
+ 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(){});}
1372
+ // Settings: the language, your computer's or any you name
1373
+ var LANG=null;
1374
+ function langLoad(){api('/api/language').then(langRender).catch(function(){});}
1375
+ function langRender(st){var el=$('langbox');if(!el||!st)return;LANG=st;
1376
+ el.innerHTML="<p>Symbiot is in <b data-nt>"+esc(st.own||st.name)+"</b>"+(st.chosen?'.':', the language your computer uses.')+"</p>"+
1377
+ "<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>"+
1378
+ "<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>";
1379
+ var go=function(v){api('/api/language/set',{chosen:v}).then(function(){location.reload();}).catch(function(){});};
1380
+ var u=$('languse');if(u)u.addEventListener('click',function(){var v=($('langin').value||'').trim();if(v)go(v);});
1381
+ var a=$('langauto');if(a)a.addEventListener('click',function(){go('');});}
1333
1382
  // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, Free's 3 projects.
1334
1383
  function licDate(ms){try{return new Date(ms).toLocaleDateString(undefined,{day:'numeric',month:'long',year:'numeric'});}catch(e){return '';}}
1335
1384
  function licLoad(){api('/api/licence').then(licRender).catch(function(){});}
@@ -3464,7 +3513,7 @@ b.querySelectorAll('[data-check]').forEach(function(x){x.addEventListener('click
3464
3513
  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
3514
  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
3515
  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');
3516
+ function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqIcon(lqLookGet());lqTheme();try{i18nStart();}catch(e){}bd.classList.add('lq-liquid');
3468
3517
  ['(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
3518
  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
3519
  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();}});}
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
  }