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 +11 -0
- package/LICENSE +98 -21
- package/README.md +36 -0
- package/agents.mjs +27 -3
- package/ai.mjs +4 -2
- package/claudesetup.mjs +4 -1
- package/guard.mjs +8 -1
- package/lang.mjs +101 -0
- package/licence.mjs +144 -0
- package/links.mjs +7 -0
- package/mind.mjs +2 -1
- package/package.json +7 -5
- package/server.mjs +19 -4
- package/tasks.mjs +3 -0
- package/ui.mjs +73 -2
- package/writeups.mjs +6 -5
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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) {
|
|
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.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
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": "
|
|
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
|
}
|