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