symbiot 0.58.3 → 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,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.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
+
10
+ ## 0.59.0
11
+
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).
13
+ - **Symbiot doesn't work on its own code.** No agent runs in Symbiot's repo or a fork of it, and the guard stops agents editing Symbiot's installed files or its licence files.
14
+ - **Elastic License 2.0** from this version on (0.58.2 and earlier stay MIT).
15
+ - **Tests never sign in or install for real.** The test that presses every button was starting a real Claude sign-in in the browser and running Anthropic's installer; both are now switched off in tests (`SYMBIOT_NO_SIGNIN`).
5
16
  ## 0.58.3 — 2026-10-09
6
17
 
7
18
  - Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
package/LICENSE CHANGED
@@ -1,21 +1,98 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Garth White (GhostAI)
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ Copyright (c) 2026 Garth White (Ghost AI)
2
+
3
+ Symbiot is licensed under the Elastic License 2.0, below. Versions up to and
4
+ including 0.58.2 were released under the MIT License and stay under it.
5
+
6
+ Elastic License 2.0
7
+
8
+ URL: https://www.elastic.co/licensing/elastic-license
9
+
10
+ ## Acceptance
11
+
12
+ By using the software, you agree to all of the terms and conditions below.
13
+
14
+ ## Copyright License
15
+
16
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
17
+ non-sublicensable, non-transferable license to use, copy, distribute, make
18
+ available, and prepare derivative works of the software, in each case subject to
19
+ the limitations and conditions below.
20
+
21
+ ## Limitations
22
+
23
+ You may not provide the software to third parties as a hosted or managed
24
+ service, where the service provides users with access to any substantial set of
25
+ the features or functionality of the software.
26
+
27
+ You may not move, change, disable, or circumvent the license key functionality
28
+ in the software, and you may not remove or obscure any functionality in the
29
+ software that is protected by the license key.
30
+
31
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
32
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
33
+ to applicable law.
34
+
35
+ ## Patents
36
+
37
+ The licensor grants you a license, under any patent claims the licensor can
38
+ license, or becomes able to license, to make, have made, use, sell, offer for
39
+ sale, import and have imported the software, in each case subject to the
40
+ limitations and conditions in this license. This license does not cover any
41
+ patent claims that you cause to be infringed by modifications or additions to
42
+ the software. If you or your company make any written claim that the software
43
+ infringes or contributes to infringement of any patent, your patent license for
44
+ the software granted under these terms ends immediately. If your company makes
45
+ such a claim, your patent license ends immediately for work on behalf of your
46
+ company.
47
+
48
+ ## Notices
49
+
50
+ You must ensure that anyone who gets a copy of any part of the software from you
51
+ also gets a copy of these terms.
52
+
53
+ If you modify the software, you must include in any modified copies of the
54
+ software prominent notices stating that you have modified the software.
55
+
56
+ ## No Other Rights
57
+
58
+ These terms do not imply any licenses other than those expressly granted in
59
+ these terms.
60
+
61
+ ## Termination
62
+
63
+ If you use the software in violation of these terms, such use is not licensed,
64
+ and your licenses will automatically terminate. If the licensor provides you
65
+ with a notice of your violation, and you cease all violation of this license no
66
+ later than 30 days after you receive that notice, your licenses will be
67
+ reinstated retroactively. However, if you violate these terms after such
68
+ reinstatement, any additional violation of these terms will cause your licenses
69
+ to terminate automatically and permanently.
70
+
71
+ ## No Liability
72
+
73
+ *As far as the law allows, the software comes as is, without any warranty or
74
+ condition, and the licensor will not be liable to you for any damages arising
75
+ out of these terms or the use or nature of the software, under any kind of
76
+ legal claim.*
77
+
78
+ ## Definitions
79
+
80
+ The **licensor** is the entity offering these terms, and the **software** is the
81
+ software the licensor makes available under these terms, including any portion
82
+ of it.
83
+
84
+ **you** refers to the individual or entity agreeing to these terms.
85
+
86
+ **your company** is any legal entity, sole proprietorship, or other kind of
87
+ organization that you work for, plus all organizations that have control over,
88
+ are under the control of, or are under common control with that
89
+ organization. **control** means ownership of substantially all the assets of an
90
+ entity, or the power to direct its management and policies by vote, contract, or
91
+ otherwise. Control can be direct or indirect.
92
+
93
+ **your licenses** are all the licenses granted to you for the software under
94
+ these terms.
95
+
96
+ **use** means anything you do with the software requiring one of your licenses.
97
+
98
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -1419,6 +1419,42 @@ 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
+
1432
+ ## Symbiot Free and Pro
1433
+
1434
+ Every install starts with **14 days of Pro**, no key and no card. After that Symbiot
1435
+ carries on as **Free**:
1436
+
1437
+ - the orb, Home and Talk to Symbiot (typed or spoken), your week, your standup and
1438
+ what's on your plate, the Away screen;
1439
+ - **one agent at a time**, in **up to 3 projects** (the first 3 it works in; Settings →
1440
+ Symbiot Pro can free a slot for another);
1441
+ - **one connected inbox or chat**.
1442
+
1443
+ **Pro** (US$12 a month or US$99 a year) adds any number of agents at once, every project,
1444
+ every inbox and chat (all your mail, WhatsApp, LinkedIn, calendars), the marketing lane
1445
+ and Approve-and-ship. Get it from [symbiot.co.za](https://symbiot.co.za/#pro) and paste
1446
+ the key you're sent into **Settings → Symbiot Pro**. A limit never stops anything already
1447
+ running: it only keeps the next thing from starting, and says why.
1448
+
1449
+ Keys are checked on your computer (they're signed by Ghost AI; there's no account and no
1450
+ server). Symbiot never works on its own code (its repo, a fork of it, or its installed
1451
+ files), so it can't be asked to take out its own limits.
1452
+
1453
+ **Licence.** From 0.59.0 Symbiot is under the [Elastic License 2.0](LICENSE): free to use,
1454
+ change and share, but not to get round the licence key, sell it as a hosted service, or
1455
+ remove its notices. Versions up to and including 0.58.2 were released under MIT and stay
1456
+ under it.
1457
+
1422
1458
  ## Config, development and releasing
1423
1459
 
1424
1460
  Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
package/agents.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // Handing a repo to the user's agent: the saved command template, the one-click
2
2
  // presets (and the Orca IDE one), the background-job registry behind the Agents
3
3
  // tab, and the questions an unattended agent leaves for the user.
4
+ import { MARKETING_DIR } from "./marketing.mjs";
5
+ import { licenceState, isPro, can, canWorkIn } from "./licence.mjs";
4
6
  import { spawn } from "node:child_process";
5
7
  import { homedir } from "node:os";
6
8
  import { join, dirname, resolve, relative } from "node:path";
@@ -268,6 +270,7 @@ function noteSession(path, code, resumed) {
268
270
  function runHandoff(repoPath, { force = false, lost = false } = {}) {
269
271
  let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
270
272
  if (isParked(repoPath)) return { blocked: true, parked: true, questions: 0, note: PARKED_NOTE }; // even forced: that's what parking is for
273
+ const lic = licenceGate(repoPath); if (lic) return lic; // Free's limits, and never Symbiot's own code (licence.mjs); even forced
271
274
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
272
275
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
273
276
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
@@ -282,7 +285,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
282
285
  noteUntracked(repoPath);
283
286
  const lock = join(repoPath, ".symbiot", LOCK);
284
287
  const own = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null, boxEnv = sandboxEnv(box);
285
- const env = own || boxEnv ? { ...(own || {}), ...(boxEnv || {}) } : null;
288
+ const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
289
+ const env = own || boxEnv || owner ? { ...(own || {}), ...(boxEnv || {}), ...(owner || {}) } : null;
286
290
  const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
287
291
  if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
288
292
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
@@ -319,6 +323,23 @@ function nothingToDo(path) {
319
323
  return md.trim() ? `Not started: every task in ${name}'s .symbiot/TASKS.md is ticked and no answer has come in since its last run, so an agent there would have nothing to do. Send it a task first.`
320
324
  : `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
321
325
  }
326
+ // ---- Symbiot Free and Pro (licence.mjs) -------------------------------------
327
+ // A run that Free doesn't cover doesn't start; it says why, the way a parked lane does.
328
+ // Nothing running is touched. Free's 3 projects are the first 3 it works in (Settings
329
+ // can free one); the marketing lane is Pro.
330
+ function licenceGate(repoPath, st = licenceState()) {
331
+ const self = canWorkIn(repoPath, st); if (!self.ok) return { blocked: true, self: true, questions: 0, note: self.why };
332
+ if (isPro(st)) return null;
333
+ if (repoPath === MARKETING_DIR) { const m = can("marketing", {}, st); return { blocked: true, pro: true, questions: 0, note: m.why }; }
334
+ const running = HANDOFFS.filter((x) => x.handoff && x.status === "running" && x.path !== repoPath).length;
335
+ const a = can("agents", { running }, st); if (!a.ok) return { blocked: true, pro: true, questions: 0, note: a.why };
336
+ const cfg = loadConfig(), L = cfg.licence || {}, mine = Array.isArray(L.projects) ? L.projects : [];
337
+ if (!mine.includes(repoPath)) {
338
+ const p = can("projects", { count: mine.length }, st); if (!p.ok) return { blocked: true, pro: true, questions: 0, note: p.why + " Settings → Symbiot Pro can swap one of your 3 for this." };
339
+ cfg.licence = { ...L, projects: [...mine, repoPath] }; saveConfig(cfg);
340
+ }
341
+ return null;
342
+ }
322
343
  // ---- a parked lane: its tasks start no runs ----------------------------------
323
344
  // A lane blocked on something only the user can give (a token from Meta) ran 27
324
345
  // times: every answer, handover, Go and Start it anyway started it again, to say
@@ -690,9 +711,12 @@ function runInputs(path, cmd) {
690
711
  for (const f of files) { try { env += f + "\0" + readFileSync(join(path, f), "utf8") + "\0"; } catch {} }
691
712
  return { env: digest(env), answers: digest(readSymbiot(path, "ANSWERS.md")), cmd: digest(cmd), open: openTasks(path) };
692
713
  }
714
+ const MTIME_SLACK = 50; // ms: a kernel tick is 4-10 ms
693
715
  function noteBlocked(path, cmd, startedAt, code) {
694
716
  const f = join(path, ".symbiot", BLOCKED);
695
- 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 {}
696
720
  const questions = asked ? agentQuestions(path, "").questions.length : 0, inputs = runInputs(path, cmd);
697
721
  try {
698
722
  if (code === 0 && questions && inputs.open.length) writeFileSync(f, JSON.stringify({ at: Date.now(), questions, ...inputs }));
@@ -1239,4 +1263,4 @@ function agentsList() {
1239
1263
  }).concat(earlierRuns());
1240
1264
  }
1241
1265
 
1242
- export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState };
1266
+ export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
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/claudesetup.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // sign-in page in the browser and finishes by itself once they've signed in. If the
5
5
  // page shows a code instead (the browser couldn't hand it back), setup has a box for
6
6
  // it, which goes to the sign-in that's waiting. SYMBIOT_CLAUDE_INSTALL_CMD replaces
7
- // the installer (tests).
7
+ // the installer (tests); SYMBIOT_NO_SIGNIN turns both off (the smoke test presses every button).
8
8
  import { spawn } from "node:child_process";
9
9
  import { homedir } from "node:os";
10
10
  import { claudeState, claudeSpawn } from "./ai.mjs";
@@ -19,6 +19,7 @@ function installCmd(platform = process.platform) {
19
19
  }
20
20
  function installClaude() {
21
21
  if (S.installing) return claudeSetup();
22
+ if (process.env.SYMBIOT_NO_SIGNIN && !process.env.SYMBIOT_CLAUDE_INSTALL_CMD) { S.installError = "Installing is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
22
23
  S.installing = true; S.installError = ""; S.log = "";
23
24
  const c = installCmd();
24
25
  let ch; try { ch = spawn(c.cmd, c.args, { cwd: homedir(), shell: c.shell, windowsHide: true, env: process.env }); } catch (e) { S.installing = false; S.installError = String((e && e.message) || e); return claudeSetup(); }
@@ -33,6 +34,8 @@ function installClaude() {
33
34
  }
34
35
  function signInClaude() {
35
36
  if (S.signingIn) return claudeSetup();
37
+ // tests press every button: a real sign-in would open the browser on someone's real account
38
+ if (process.env.SYMBIOT_NO_SIGNIN) { S.signinError = "Signing in is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
36
39
  S.signingIn = true; S.signinError = "";
37
40
  let ch; try { ch = claudeSpawn(["auth", "login", "--claudeai"], { cwd: homedir(), stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { S.signingIn = false; S.signinError = String((e && e.message) || e); return claudeSetup(); }
38
41
  S.child = ch;
package/guard.mjs CHANGED
@@ -25,7 +25,12 @@ const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, ho
25
25
  const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
26
26
  const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
27
27
  function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
28
- function symbiotConfig(p, home) { return p === join(home, ".config", "symbiot", "config.json"); }
28
+ function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); return p === join(d, "config.json") || p === join(d, "licence-revoked.json"); }
29
+ // Symbiot's own installed files (where this guard runs from): never edited by an agent, so
30
+ // it can't be asked to take out its own limits or rules. Ghost AI's owner key lets its own
31
+ // runs through (SYMBIOT_OWNER, set by Symbiot, not by the agent).
32
+ const SELF = resolve(fileURLToPath(new URL(".", import.meta.url)));
33
+ const selfFile = (p) => !process.env.SYMBIOT_OWNER && under(p, SELF);
29
34
 
30
35
  // the branch a bare `git push` pushes: the one checked out in cwd
31
36
  function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
@@ -37,6 +42,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
37
42
  const s = part.trim(); if (!s) continue;
38
43
  const w = s.split(/\s+/);
39
44
  if (/^(sudo|su|doas)$/.test(w[0])) return { why: "sudo: only you run things as root" };
45
+ if (!process.env.SYMBIOT_OWNER && (s.includes(SELF + "/") || s.includes(SELF + " ") || s.endsWith(SELF))) return { why: "Symbiot doesn't edit its own code" };
40
46
  if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
41
47
  if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(w[0]) || (/^dd$/.test(w[0]) && /\bof=\/dev\//.test(s))) return { why: "that writes a disk directly" };
42
48
  if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
@@ -83,6 +89,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
83
89
  const abs = expand(p, home, cwd);
84
90
  if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
85
91
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
92
+ if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && selfFile(abs)) return { why: "Symbiot doesn't edit its own code" };
86
93
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
87
94
  return null;
88
95
  }
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/licence.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // symbiot — Symbiot Free and Symbiot Pro.
2
+ //
3
+ // Free: the orb and Home, the week, standups, voice, the Away screen, one agent at a
4
+ // time, up to 3 projects and one connected inbox. Pro: any number of agents at once,
5
+ // every project, every inbox and chat, the marketing lane and Approve-and-ship.
6
+ //
7
+ // A Pro key is a few lines of text signed by Ghost AI's private key (Ed25519); every
8
+ // copy of Symbiot carries the public key below and checks keys offline: no account,
9
+ // no server. Keys can carry an end date; a key that's been cancelled is listed at
10
+ // symbiot.co.za/licences/revoked.json, which Symbiot reads about once a day (missing
11
+ // it changes nothing). Everyone gets a 14-day Pro trial, from the first time this
12
+ // version runs. Nothing already running is ever stopped: a limit only stops the next
13
+ // thing from starting, with a line saying why and what Pro adds.
14
+ //
15
+ // Symbiot never works on its own code (its repo, a fork of it, or its installed
16
+ // files), so it can't be asked to take its own limits out; Ghost AI's owner key is
17
+ // the exception. This is part of the licence (Elastic License 2.0: no getting round
18
+ // the licence key).
19
+ //
20
+ // SYMBIOT_LICENCE_PUBLIC_KEY replaces the public key (tests sign with their own).
21
+ import { createPublicKey, verify as edVerify } from "node:crypto";
22
+ import { existsSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
23
+ import { join, dirname, resolve, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawnSync } from "node:child_process";
26
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
27
+
28
+ const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
29
+ MCowBQYDK2VwAyEAGLISPmrOCX+t/KVY/u99rmFhjbXFbvRCGw1tStG1t8A=
30
+ -----END PUBLIC KEY-----`;
31
+ const REVOKED_URL = "https://symbiot.co.za/licences/revoked.json";
32
+ const TRIAL_DAYS = 14, GRACE_DAYS = 7, DAY = 86400000;
33
+ const FREE = { agents: 1, projects: 3, inboxes: 1 };
34
+ const PRICE = "US$12 a month or US$99 a year";
35
+ const UPGRADE_URL = "https://symbiot.co.za/#pro";
36
+
37
+ const unb64u = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
38
+ const pubKey = () => createPublicKey(process.env.SYMBIOT_LICENCE_PUBLIC_KEY || PUBLIC_KEY);
39
+
40
+ // "SYM1-<payload>.<signature>", whitespace and line breaks ignored (keys get pasted from mail).
41
+ function readKey(text) {
42
+ const t = String(text || "").replace(/\s+/g, "");
43
+ const m = /^SYM1-([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(t);
44
+ if (!m) return { ok: false, why: "That isn't a Symbiot key. It starts with SYM1-." };
45
+ let p;
46
+ try {
47
+ if (!edVerify(null, Buffer.from(m[1]), pubKey(), unb64u(m[2]))) return { ok: false, why: "That key wasn't issued by Ghost AI (its signature doesn't match)." };
48
+ p = JSON.parse(unb64u(m[1]).toString("utf8"));
49
+ } catch { return { ok: false, why: "That key is damaged. Copy it again, all of it." }; }
50
+ if (!p || p.v !== 1 || !["pro", "owner"].includes(p.p) || !p.id) return { ok: false, why: "That key is for something this version of Symbiot doesn't know." };
51
+ return { ok: true, key: t, id: String(p.id), plan: p.p, email: String(p.e || ""), name: String(p.n || ""), issued: Number(p.i) || 0, expires: Number(p.x) || 0 };
52
+ }
53
+
54
+ // Cancelled keys, from symbiot.co.za, refreshed at most once a day; a failed fetch keeps the last list.
55
+ const REVOKED_FILE = () => join(CONFIG_DIR, "licence-revoked.json");
56
+ function revokedIds() { try { return new Set(JSON.parse(readFileSync(REVOKED_FILE(), "utf8")).ids || []); } catch { return new Set(); } }
57
+ async function refreshRevoked(now = Date.now()) {
58
+ try {
59
+ const cur = existsSync(REVOKED_FILE()) ? JSON.parse(readFileSync(REVOKED_FILE(), "utf8")) : {};
60
+ if (cur.at && now - cur.at < DAY) return false;
61
+ const r = await fetch(process.env.SYMBIOT_REVOKED_URL || REVOKED_URL, { signal: AbortSignal.timeout(8000) });
62
+ if (!r.ok) return false;
63
+ const j = await r.json();
64
+ writeFileSync(REVOKED_FILE(), JSON.stringify({ at: now, ids: Array.isArray(j.ids) ? j.ids.map(String) : [] }));
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+
69
+ // Where you stand: { plan: "pro" | "owner" | "trial" | "free", ... }. Starts the trial clock on first sight.
70
+ function licenceState(now = Date.now(), cfg = loadConfig()) {
71
+ const L = cfg.licence || {};
72
+ if (!L.trialStart) { L.trialStart = now; cfg.licence = L; try { saveConfig(cfg); } catch {} }
73
+ const base = { limits: FREE, price: PRICE, upgrade: UPGRADE_URL };
74
+ if (L.key) {
75
+ const k = readKey(L.key);
76
+ if (k.ok && revokedIds().has(k.id)) return { ...base, plan: "free", keyProblem: "This key has been cancelled. If that's a mistake, reply to the email it came in." };
77
+ if (k.ok) {
78
+ const left = k.expires ? k.expires - now : Infinity;
79
+ const who = { email: k.email, name: k.name, keyId: k.id, ...(k.expires ? { expires: k.expires } : {}) };
80
+ if (left > 0) return { ...base, plan: k.plan, ...who, ...(left < 14 * DAY ? { renewSoon: Math.ceil(left / DAY) } : {}) };
81
+ if (left > -GRACE_DAYS * DAY) return { ...base, plan: k.plan, ...who, grace: Math.ceil((left + GRACE_DAYS * DAY) / DAY) };
82
+ return { ...base, plan: "free", ...who, keyProblem: "Your Pro key has ended. Renew it to keep Pro." };
83
+ }
84
+ return { ...base, plan: "free", keyProblem: k.why };
85
+ }
86
+ const trialLeft = L.trialStart + TRIAL_DAYS * DAY - now;
87
+ if (trialLeft > 0) return { ...base, plan: "trial", trialDaysLeft: Math.ceil(trialLeft / DAY) };
88
+ return { ...base, plan: "free", trialEnded: true };
89
+ }
90
+ const isPro = (st = licenceState()) => st.plan === "pro" || st.plan === "owner" || st.plan === "trial";
91
+
92
+ function setKey(text) {
93
+ const k = readKey(text);
94
+ if (!k.ok) return { error: k.why };
95
+ if (revokedIds().has(k.id)) return { error: "This key has been cancelled." };
96
+ const cfg = loadConfig(); cfg.licence = { ...(cfg.licence || {}), key: k.key }; saveConfig(cfg);
97
+ return licenceState();
98
+ }
99
+ function clearKey() { const cfg = loadConfig(); if (cfg.licence) { delete cfg.licence.key; saveConfig(cfg); } return licenceState(); }
100
+
101
+ // What Free stops, and how it says so. ctx: { running } agents now, { count } projects/inboxes now.
102
+ const WHY = {
103
+ agents: (n) => `Symbiot Free runs one agent at a time, and one is at work. This one starts when it's done, or Pro runs as many as you like at once (${PRICE}).`,
104
+ projects: () => `Symbiot Free works in ${FREE.projects} projects. Pro works in all of them (${PRICE}).`,
105
+ inboxes: () => `Symbiot Free connects one inbox or chat. Pro connects all of them: every mail account, WhatsApp, LinkedIn and your calendars (${PRICE}).`,
106
+ marketing: () => `The marketing lane is part of Symbiot Pro (${PRICE}).`,
107
+ ship: () => `Approve-and-ship (Symbiot opening the pull request and shipping it) is part of Symbiot Pro (${PRICE}). Your agent's changes are kept on their branch either way.`,
108
+ };
109
+ function can(feature, ctx = {}, st = licenceState()) {
110
+ if (isPro(st)) return { ok: true };
111
+ const n = Number(ctx.count ?? ctx.running ?? 0);
112
+ if (feature === "agents" && n < FREE.agents) return { ok: true };
113
+ if (feature === "projects" && n < FREE.projects) return { ok: true };
114
+ if (feature === "inboxes" && n < FREE.inboxes) return { ok: true };
115
+ if (!WHY[feature]) return { ok: true };
116
+ return { ok: false, pro: true, feature, why: WHY[feature](n), upgrade: UPGRADE_URL };
117
+ }
118
+
119
+ // ---- Symbiot never works on its own code -------------------------------------
120
+ const HERE = (() => { try { return realpathSync(dirname(fileURLToPath(import.meta.url))); } catch { return dirname(fileURLToPath(import.meta.url)); } })();
121
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
122
+ const inside = (p, root) => { const a = real(p), b = real(root); return a === b || a.startsWith(b + sep); };
123
+ // Its repo or a fork (whatever it's renamed to), or the copy of Symbiot that's installed.
124
+ function isSymbiotCode(dir) {
125
+ if (!dir) return false;
126
+ const d = real(dir);
127
+ if (inside(d, HERE)) return true;
128
+ let top = d;
129
+ try { const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: d, encoding: "utf8", timeout: 5000, windowsHide: true }); if (r.status === 0 && r.stdout.trim()) top = r.stdout.trim(); } catch {}
130
+ try { const pj = JSON.parse(readFileSync(join(top, "package.json"), "utf8")); if (pj.name === "symbiot" || pj.name === "symbiot-desktop") return true; } catch {}
131
+ const has = (f) => existsSync(join(top, f));
132
+ if (has("licence.mjs") && has("ui.mjs") && has("server.mjs")) return true;
133
+ try { if (/EMBEDDED_UI/.test(readFileSync(join(top, "ui.mjs"), "utf8"))) return true; } catch {}
134
+ try { const r = spawnSync("git", ["remote", "-v"], { cwd: top, encoding: "utf8", timeout: 5000, windowsHide: true }); if (/[/:]symbiot(\.git)?\s/i.test(r.stdout || "")) return true; } catch {}
135
+ return false;
136
+ }
137
+ const SELF_WHY = "Symbiot doesn't work on its own code (Symbiot's repo, a copy of it, or its installed files). That keeps its licence and its safety rules from being edited away.";
138
+ function canWorkIn(dir, st = licenceState()) {
139
+ if (!isSymbiotCode(dir)) return { ok: true };
140
+ if (st.plan === "owner") return { ok: true };
141
+ return { ok: false, self: true, why: SELF_WHY };
142
+ }
143
+
144
+ export { readKey, licenceState, isPro, setKey, clearKey, can, refreshRevoked, isSymbiotCode, canWorkIn, FREE, PRICE, TRIAL_DAYS, SELF_WHY, HERE as SYMBIOT_HOME };
package/links.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  // trusted sites, there's no command an agent could call to link a site itself.
19
19
  //
20
20
  // What's linked is config.linked: { <id>: { at, watch?, hosts: [trusted by the link] } }.
21
+ import { can } from "./licence.mjs";
21
22
  import { join } from "node:path";
22
23
  import { readFileSync } from "node:fs";
23
24
  import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
@@ -100,10 +101,16 @@ function linksState() {
100
101
  // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
101
102
  // that's already there just opens it again to sign in (signed out, or a second
102
103
  // account). Gives { ok, url, item } or { error }.
104
+ // what counts as an inbox or chat for Free's one
105
+ const INBOX_GROUPS = new Set(["Mail", "Calendar", "Chat", "Social"]);
103
106
  async function linkSite(id, { open = signIn } = {}) {
104
107
  const e = catalog().list.find((x) => x.id === id);
105
108
  if (!e) return { error: `No link called ${id}.` };
106
109
  const l = linked(), had = l[id], before = new Set(trustedSites()), added = [];
110
+ if (!had && INBOX_GROUPS.has(e.group)) { // Free connects one inbox or chat (licence.mjs)
111
+ const n = Object.keys(l).filter((k) => { const x = catalog().list.find((c) => c.id === k); return x && INBOX_GROUPS.has(x.group); }).length;
112
+ const ok = can("inboxes", { count: n }); if (!ok.ok) return { error: ok.why, pro: true };
113
+ }
107
114
  for (const h of e.hosts) if (!before.has(h)) { const r = trustSite(h); if (r && r.ok) added.push(h); }
108
115
  let watch = had && had.watch;
109
116
  if (e.watch && !(watch && watchState().watches.some((x) => x.id === watch))) {
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,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.58.3",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.60.0",
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": {
7
7
  "symbiot": "index.mjs"
@@ -52,7 +52,9 @@
52
52
  "estimate.mjs",
53
53
  "icon.ico",
54
54
  "claudesetup.mjs",
55
- "voice.mjs"
55
+ "voice.mjs",
56
+ "licence.mjs",
57
+ "lang.mjs"
56
58
  ],
57
59
  "engines": {
58
60
  "node": ">=18"
@@ -72,7 +74,7 @@
72
74
  "cli"
73
75
  ],
74
76
  "author": "Garth White <garth@ghost-ai.co.za>",
75
- "license": "MIT",
77
+ "license": "Elastic-2.0",
76
78
  "repository": {
77
79
  "type": "git",
78
80
  "url": "git+https://github.com/GarthGhostai/symbiot.git"
@@ -85,7 +87,7 @@
85
87
  "@anthropic-ai/sdk": "^0.128.0"
86
88
  },
87
89
  "scripts": {
88
- "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/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",
89
91
  "lint": "eslint .",
90
92
  "postinstall": "node postinstall.mjs"
91
93
  },
package/server.mjs CHANGED
@@ -2,6 +2,8 @@
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";
6
+ import { licenceState, setKey, clearKey, can, refreshRevoked, FREE } from "./licence.mjs";
5
7
  import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
6
8
  import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
7
9
  import { spawn, spawnSync } from "node:child_process";
@@ -152,6 +154,8 @@ const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_
152
154
  const SANDBOX = !!process.env.SYMBIOT_SANDBOX;
153
155
  // the installed app (symbiot-desktop): it updates itself and made its own shortcuts
154
156
  const DESKTOP = process.env.SYMBIOT_DESKTOP === "1"; // symbiot app --fresh (sandbox.mjs)
157
+ // the licence as Settings shows it: the plan, and Free's 3 projects
158
+ function licenceView() { const st = licenceState(); const L = loadConfig().licence || {}; return { ...st, projects: (L.projects || []).map((p) => ({ path: p, name: p.split(/[\\/]/).pop() })), free: FREE }; } // symbiot app --fresh (sandbox.mjs)
155
159
  const FIRST_RUN = !existsSync(CONFIG_PATH); // no config yet when Symbiot started: a brand-new install
156
160
  function openApp(url) {
157
161
  try {
@@ -238,6 +242,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
238
242
  if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
239
243
  // your projects: kept, and found again off the main thread, so nothing waits on a search
240
244
  setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
245
+ if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
241
246
  { const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
242
247
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
243
248
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
@@ -390,14 +395,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
390
395
  }
391
396
  // Marketing's own lane (marketing.mjs): a task for it, tagged with its product; another
392
397
  // lane's marketing task moved to it; its agent started; a draft its agent wrote, opened
393
- if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
394
- if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
395
- if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
398
+ if (u.pathname === "/api/marketing/task" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
399
+ if (u.pathname === "/api/marketing/move" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
400
+ if (u.pathname === "/api/marketing/go" && req.method === "POST") { const g = can("marketing"); return json(res, g.ok ? marketingGo() : { error: g.why, pro: true }); }
396
401
  // a draft as the post it will be (its preview, notes apart), its picture or video, and
397
402
  // your Approve or Skip on it: approved, its agent posts it through Symbiot's browser
398
403
  if (u.pathname === "/api/marketing/draft") return json(res, draftPreview(String(u.searchParams.get("rel") || "")));
399
404
  if (u.pathname === "/api/marketing/media") { const m = laneMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=300" }); res.end(readFileSync(m.file)); return; }
400
- if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
405
+ if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
401
406
  if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
402
407
  // the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
403
408
  // a tray), a box switched on or off and the capture blurred again, a capture as a post's media
@@ -511,6 +516,16 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
511
516
  if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
512
517
  if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
513
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) : []) }); }
524
+ // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, a Free project slot freed
525
+ if (u.pathname === "/api/licence") return json(res, licenceView());
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()); }
527
+ if (u.pathname === "/api/licence/clear" && req.method === "POST") { clearKey(); return json(res, licenceView()); }
528
+ if (u.pathname === "/api/licence/project/free" && req.method === "POST") { const b = await readBody(req); const c = loadConfig(); const L = c.licence || {}; L.projects = (L.projects || []).filter((p) => p !== String(b.path || "")); c.licence = L; saveConfig(c); return json(res, licenceView()); }
514
529
  if (u.pathname === "/api/update" && req.method === "POST" && DESKTOP) return json(res, { error: "This Symbiot updates with its app." });
515
530
  if (u.pathname === "/api/update" && req.method === "POST") {
516
531
  // Install the exact newest version (see updateCmd), then relaunch this
package/tasks.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // each repo gets (.symbiot/TASKS.md), what the agent ticked (review), and
3
3
  // Approve: branch, commit, push, PR, and the version bump and npm release facts
4
4
  // it shows.
5
+ import { can } from "./licence.mjs";
5
6
  import { spawn, spawnSync } from "node:child_process";
6
7
  import { join } from "node:path";
7
8
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
@@ -558,6 +559,7 @@ async function shipChanges(path, texts, opts = {}) {
558
559
  function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
559
560
  function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
560
561
  async function approveRepo(repo, opts = {}) {
562
+ const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true }; // Pro (licence.mjs); the work stays on its branch
561
563
  const waiting = (t) => t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
562
564
  const shipped = waiting(loadTasks());
563
565
  if (!shipped.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
@@ -578,6 +580,7 @@ async function approveRepo(repo, opts = {}) {
578
580
  // repo's open tasks the changes finished (the run didn't tick them): they're
579
581
  // approved with the changes, as Approve does, so they don't go out again.
580
582
  async function approveChanges(repo, opts = {}) {
583
+ const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true };
581
584
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return await approveRepo(repo, opts);
582
585
  const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
583
586
  const busy = stillWorking(repo, path); if (busy) return busy;
package/ui.mjs CHANGED
@@ -802,6 +802,8 @@ 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)}
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}
805
807
  .ssh{margin:0 0 10px;font:600 16px var(--sans);letter-spacing:-.01em;color:var(--bone);display:flex;align-items:baseline;gap:8px}
806
808
  .ssh .muted{font-weight:400;font-size:12.5px}
807
809
  body.lq-liquid .sset > label:not(.check):not(.swl){display:block;font:500 12.5px var(--sans);color:var(--faint);margin:12px 0 6px}
@@ -1168,6 +1170,8 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1168
1170
  </section>
1169
1171
  <section id="panel-settings" class="hidden">
1170
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>
1174
+ <div class="sset" id="licset"><h3 class="ssh">Symbiot Pro</h3><div id="licbox"><span class="muted">Checking…</span></div></div>
1171
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>
1172
1176
  <div class="sset"><h3 class="ssh">Your AI</h3>
1173
1177
  <label>Which AI should Symbiot write with?</label>
@@ -1311,7 +1315,7 @@ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==
1311
1315
  $('panel-map').classList.toggle('hidden',!isMap);
1312
1316
  $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
1313
1317
  $('panel-run').classList.toggle('hidden',!isRun);
1314
- $('panel-settings').classList.toggle('hidden',!isSet);if(isSet)loadFirstSteps();
1318
+ $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();langLoad();}
1315
1319
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1316
1320
  $('panel-drift').classList.toggle('hidden',!isDrift);
1317
1321
  $('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
@@ -1328,6 +1332,73 @@ lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
1328
1332
  // takes you to its part, lit up. The block goes once all are done.
1329
1333
  var FSKIP='symbiot-skip-company';
1330
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('');});}
1382
+ // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, Free's 3 projects.
1383
+ function licDate(ms){try{return new Date(ms).toLocaleDateString(undefined,{day:'numeric',month:'long',year:'numeric'});}catch(e){return '';}}
1384
+ function licLoad(){api('/api/licence').then(licRender).catch(function(){});}
1385
+ function licRender(st,msg){var el=$('licbox');if(!el||!st)return;var h='';
1386
+ if(st.plan==='owner')h+="<p>Owner key"+(st.email?' for '+esc(st.email):'')+": everything, including Symbiot working on its own code.</p>";
1387
+ else if(st.plan==='pro'){h+="<p><b>Pro</b>"+(st.email?' for '+esc(st.email):'')+(st.expires?', until '+esc(licDate(st.expires)):'')+'.</p>';
1388
+ if(st.grace)h+="<p class='onbnote'>Your key has ended. Pro carries on for "+st.grace+" more day"+(st.grace===1?'':'s')+": renew it to keep it.</p>";
1389
+ else if(st.renewSoon)h+="<p class='muted'>It ends in "+st.renewSoon+" day"+(st.renewSoon===1?'':'s')+".</p>";}
1390
+ else if(st.plan==='trial')h+="<p><b>Pro trial</b>: "+st.trialDaysLeft+" day"+(st.trialDaysLeft===1?'':'s')+" left. After that Symbiot carries on as Free, unless you add a Pro key.</p>";
1391
+ else h+="<p><b>Free</b>: one agent at a time, up to "+st.free.projects+" projects and one inbox or chat."+(st.trialEnded?' Your Pro trial has ended.':'')+"</p>";
1392
+ if(st.keyProblem)h+="<p class='onbnote'>"+esc(st.keyProblem)+"</p>";
1393
+ if(st.plan==='free'||st.plan==='trial'){
1394
+ if(st.projects&&st.projects.length)h+="<p class='muted' style='margin-top:10px'>Free’s "+st.free.projects+" projects: the first ones Symbiot worked in.</p><div class='licproj'>"+st.projects.map(function(p,i){return "<span class='chip'>"+esc(p.name)+" <button class='back' data-i='"+i+"' title='Free this slot for another project'>free up</button></span>";}).join(' ')+"</div>";
1395
+ h+="<p style='margin-top:12px'>Pro: as many agents at once as you like, every project, every inbox and chat, the marketing lane and Approve-and-ship. "+esc(st.price)+". <a href='"+esc(st.upgrade)+"' target='_blank' rel='noopener'>Get Pro</a></p>";}
1396
+ if(st.plan!=='owner'){h+="<div class='row' style='margin-top:10px'>"+(st.plan==='pro'?"<button class='ghost' id='licclear'>Remove this key</button>":"<textarea id='lickey' rows='2' placeholder='Paste your Pro key (it starts SYM1-)' style='flex:1'></textarea><button class='act' id='licsave'>Add key</button>")+"</div>";}
1397
+ if(msg)h+="<p class='onbnote' style='margin-top:8px'>"+esc(msg)+"</p>";
1398
+ el.innerHTML=h;
1399
+ var sv=$('licsave');if(sv)sv.addEventListener('click',function(){var k=($('lickey').value||'').trim();if(!k)return;api('/api/licence/key',{key:k}).then(function(r){if(r&&r.error){licRender(st,r.error);return;}licRender(r,'Thanks: Symbiot Pro is on.');}).catch(function(){});});
1400
+ var cl=$('licclear');if(cl)cl.addEventListener('click',function(){api('/api/licence/clear',{}).then(function(r){licRender(r);}).catch(function(){});});
1401
+ el.querySelectorAll('.licproj button[data-i]').forEach(function(b){b.addEventListener('click',function(){var p=st.projects[+b.getAttribute('data-i')];if(!p)return;api('/api/licence/project/free',{path:p.path}).then(function(r){licRender(r);}).catch(function(){});});});}
1331
1402
  function loadFirstSteps(){api('/api/firststeps').then(function(f){var el=$('firststeps');if(!el||!f||!f.steps)return;
1332
1403
  var st=f.steps.map(function(s){return s.id==='company'&&!s.done&&fsSkipped()?Object.assign({},s,{done:true,skipped:true}):s;}),next=st.filter(function(s){return !s.done;})[0];
1333
1404
  if(!next){el.innerHTML='';el.classList.add('hidden');return;}el.classList.remove('hidden');
@@ -3442,7 +3513,7 @@ b.querySelectorAll('[data-check]').forEach(function(x){x.addEventListener('click
3442
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();});});});
3443
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();});});});}
3444
3515
  function lqIcon(l){var k=document.querySelector('link[rel=icon]');if(k)k.setAttribute('href','/favicon.svg?look='+encodeURIComponent(l||'ferro'));}
3445
- 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');
3446
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){}});
3447
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();});
3448
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
  }