symbiot 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
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.56.0
6
+
7
+ - **Symbiot in your app menu, no terminal.** On Linux, `npm install -g symbiot` puts Symbiot in your app menu with its own icon, the orb. Click it and Symbiot starts in the background and opens its window; click it again and the window comes up. You never need a terminal to run it. Its window shows the orb in your taskbar too, and the menu entry keeps itself pointing at the installed Symbiot (a new Node, a moved install).
8
+ - **`symbiot uninstall`** removes Symbiot and everything it added: the menu entry and icon, start at login, the Away shortcut, and its data (`--keep-data` keeps your settings, tasks and memory). Claude Code and its sign-in stay.
9
+ - `symbiot open` does what the icon does, from a terminal.
10
+
11
+ ## 0.55.3
12
+
13
+ - **Your Claude subscription, no API key.** With Claude Code signed in to your Claude account (Pro or Max), Symbiot now answers through it: chats, write-ups, posts, screenshots. Nothing to connect, no separate bill. It comes first when you haven't picked an AI, ahead of an old saved key. `symbiot login` and Settings → Your AI offer it first; if Claude Code isn't signed in yet, they tell you to run `claude` once and sign in. Before, a fresh install sent you to `symbiot login`, which only asked for an API key.
14
+ - **Home remembers the conversation you're having.** Home's chat didn't pass its own last turns to the AI, only what was said elsewhere in the app, so a "yeah" to its own "want that?" was read as an answer to something else and the task you'd agreed to was never made. Home now keeps its own thread: the last 16 turns, each in full, from the last 12 hours. A short answer (yes, yeah, ok, do it, 2) always answers what it just said there.
15
+ - **Ideas get hashed out first.** Bring Symbiot an idea and it sharpens it with you (what's strong, where it's weak, what's missing) before proposing a task, and files it once you agree.
16
+ - **An ambiguous ask gets readings, not a guess.** When a request could mean two things (which project, how big, what done looks like), Symbiot gives two or three numbered readings, its pick first, and asks which. Clear asks still go straight through.
17
+
5
18
  ## 0.55.2 — 2026-10-07
6
19
 
7
20
  - Fixed a feature description being read as something waiting on you: an agent's "lit amber when something needs you: work waiting for your OK" showed as a Marketing lane step only you could do. A "when / if / once / until" before the words now means it describes behaviour.
package/README.md CHANGED
@@ -40,6 +40,17 @@ agent and brings its work back to you to approve.
40
40
  npm install -g symbiot # or run without installing: npx symbiot week
41
41
  ```
42
42
 
43
+ With Claude Code signed in to your Claude account, that's all: Symbiot uses your
44
+ subscription, no API key. See [Connect it (once)](#connect-it-once) for the other options.
45
+
46
+ On Linux, installing puts **Symbiot in your app menu**, with its icon. Open it from
47
+ there: it starts in the background (no terminal) and opens its window, or brings the
48
+ window up if it's already running. You don't need a terminal again; `symbiot open`
49
+ does the same from one.
50
+
51
+ To remove it and everything it added (menu entry, icon, start at login, shortcut,
52
+ its data): `symbiot uninstall` (`--keep-data` keeps your settings, tasks and memory).
53
+
43
54
  ## Prefer a window? `symbiot app`
44
55
 
45
56
  ```bash
@@ -911,18 +922,26 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
911
922
 
912
923
  ## Connect it (once)
913
924
 
914
- Pick the AI you want it to write with:
925
+ **Have a Claude subscription (Pro or Max)? There's nothing to connect.** If
926
+ [Claude Code](https://claude.com/claude-code) is installed and signed in with your
927
+ Claude account (run `claude` once and sign in), Symbiot answers through it: no API
928
+ key, no separate bill. It runs Claude Code headless with Symbiot's own instructions,
929
+ no tools (Read only, for a screenshot you attach), none of your settings, plugins or
930
+ servers, and no session kept.
931
+
932
+ Otherwise, pick the AI you want it to write with:
915
933
 
916
934
  ```bash
917
- symbiot login # choose Claude, OpenAI, Gemini, or a local model (Ollama)
935
+ symbiot login # your Claude subscription, Claude (API key), OpenAI, Gemini, or a local model (Ollama)
918
936
  ```
919
937
 
920
- You'll get a short menu; paste that provider's API key (or, for Ollama, just point
938
+ You'll get a short menu; for a hosted model paste its API key (for Ollama, just point
921
939
  it at your local server) and it's saved to `~/.config/symbiot/config.json`. The
922
940
  app's Settings tab does the same.
923
941
 
924
942
  | Provider | Get a key | Default model |
925
943
  |----------|-----------|---------------|
944
+ | Your Claude subscription (Claude Code) | no key: sign in to Claude Code | your plan's default |
926
945
  | Claude (Anthropic) | <https://console.anthropic.com/settings/keys> | `claude-opus-5-5` |
927
946
  | OpenAI (GPT) | <https://platform.openai.com/api-keys> | `gpt-4o-mini` |
928
947
  | Gemini (Google) | <https://aistudio.google.com/apikey> | `gemini-1.5-flash` |
package/ai.mjs CHANGED
@@ -1,23 +1,52 @@
1
1
  // The AI Symbiot writes with: the providers, which one is set up, one call per
2
2
  // provider (same in, same out), and the local-model (Ollama) setup.
3
3
  import Anthropic from "@anthropic-ai/sdk";
4
- import { spawn } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { homedir, totalmem, cpus as oscpus } from "node:os";
6
6
  import { join } from "node:path";
7
- import { existsSync } from "node:fs";
8
- import { loadConfig, saveConfig, sh } from "./core.mjs";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { loadConfig, saveConfig, sh, hasCmd, CONFIG_DIR } from "./core.mjs";
9
9
 
10
10
  const MAX_TOKENS = 1600;
11
11
 
12
12
  // The providers Symbiot can write with. Models are sensible defaults; override
13
13
  // per provider at login, or globally with SYMBIOT_MODEL.
14
14
  const PROVIDERS = {
15
+ // your Claude subscription (Pro or Max), through Claude Code signed in on this computer: no key
16
+ claude: { label: "Your Claude subscription (Claude Code)", sub: true, keyUrl: "https://claude.com/claude-code", keyName: null, model: "" },
15
17
  anthropic: { label: "Claude (Anthropic)", env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], keyUrl: "https://console.anthropic.com/settings/keys", keyName: "Anthropic API key (sk-ant-…)", model: "claude-opus-5-5" },
16
18
  openai: { label: "OpenAI (GPT)", env: ["OPENAI_API_KEY"], keyUrl: "https://platform.openai.com/api-keys", keyName: "OpenAI API key (sk-…)", model: "gpt-4o-mini" },
17
19
  gemini: { label: "Gemini (Google)", env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], keyUrl: "https://aistudio.google.com/apikey", keyName: "Google AI API key", model: "gemini-1.5-flash" },
18
20
  ollama: { label: "Local model (Ollama)", local: true, keyUrl: "https://ollama.com", keyName: null, model: "llama3.1" },
19
21
  };
20
22
 
23
+ // ---- your Claude subscription -------------------------------------------------
24
+ // Claude Code signed in with a Claude account (Pro or Max) answers with that account,
25
+ // no API key: Symbiot runs it headless (claude -p), the way it already runs your
26
+ // agents. Whether it's there and signed in: `claude auth status` (a quarter of a
27
+ // second), kept 5 minutes. SYMBIOT_CLAUDE_CMD replaces the command (the tests' stand-in).
28
+ const CLAUDE_BIN = () => process.env.SYMBIOT_CLAUDE_CMD || "claude";
29
+ let CLAUDE_SEEN = null;
30
+ function claudeState(fresh = false) {
31
+ if (!fresh && CLAUDE_SEEN && Date.now() - CLAUDE_SEEN.at < 300000) return CLAUDE_SEEN;
32
+ let installed = !!process.env.SYMBIOT_CLAUDE_CMD || hasCmd("claude"), signedIn = false;
33
+ if (installed) {
34
+ try {
35
+ const r = spawnSync(CLAUDE_BIN(), ["auth", "status"], { encoding: "utf8", timeout: 8000, env: process.env });
36
+ if (r.error && r.error.code === "ENOENT") installed = false;
37
+ else { const j = JSON.parse(String(r.stdout || "").trim() || "{}"); signedIn = !!j.loggedIn && j.authMethod !== "apiKey"; }
38
+ } catch {}
39
+ }
40
+ CLAUDE_SEEN = { installed, signedIn, at: Date.now() };
41
+ return CLAUDE_SEEN;
42
+ }
43
+ const claudeReady = (fresh) => claudeState(fresh).signedIn;
44
+ // What to do when it isn't: one line, for the CLI and the app.
45
+ function claudeHelp(st = claudeState()) {
46
+ return st.installed ? "Claude Code is installed but not signed in: run claude once in a terminal and sign in with your Claude account, then try again."
47
+ : "Install Claude Code ( npm install -g @anthropic-ai/claude-code ), run claude once and sign in with your Claude account, then try again.";
48
+ }
49
+
21
50
  // ---- config + provider resolution (config files: core.mjs) -----------------
22
51
  function antProfileExists() {
23
52
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
@@ -28,17 +57,22 @@ function envKey(provider) {
28
57
  return null;
29
58
  }
30
59
  // Returns { provider, key?, baseUrl?, model, source } or null if nothing set up.
31
- // Order: saved choice → legacy saved key → env keys → an `ant` profile.
60
+ // Order: saved choice → your Claude subscription (Claude Code signed in) → legacy
61
+ // saved key → env keys → an `ant` profile. With no choice saved, the subscription
62
+ // comes before a key: it's what most people already pay for.
32
63
  function resolveProvider() {
33
64
  const cfg = loadConfig();
34
65
  const m = process.env.SYMBIOT_MODEL;
35
- if (cfg.provider && PROVIDERS[cfg.provider]) {
66
+ const sub = () => ({ provider: "claude", model: m || (cfg.claude || {}).model || "", source: "your Claude subscription (Claude Code)" });
67
+ if (cfg.provider === "claude" && claudeReady()) return sub();
68
+ if (cfg.provider && cfg.provider !== "claude" && PROVIDERS[cfg.provider]) {
36
69
  const p = cfg.provider, pc = cfg[p] || {};
37
70
  if (p === "ollama") return { provider: p, baseUrl: pc.baseUrl || "http://localhost:11434", model: m || pc.model || PROVIDERS.ollama.model, source: "saved login" };
38
71
  const key = pc.apiKey || envKey(p);
39
72
  if (key || (p === "anthropic" && (process.env.ANTHROPIC_AUTH_TOKEN || antProfileExists())))
40
73
  return { provider: p, key, model: m || pc.model || PROVIDERS[p].model, source: pc.apiKey ? "saved login" : "environment" };
41
74
  }
75
+ if ((!cfg.provider || cfg.provider === "claude") && claudeReady()) return sub();
42
76
  if (cfg.apiKey) return { provider: "anthropic", key: cfg.apiKey, model: m || PROVIDERS.anthropic.model, source: "saved login (~/.config/symbiot)" };
43
77
  if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return { provider: "anthropic", key: process.env.ANTHROPIC_API_KEY, model: m || PROVIDERS.anthropic.model, source: "ANTHROPIC_* (environment)" };
44
78
  if (process.env.OPENAI_API_KEY) return { provider: "openai", key: process.env.OPENAI_API_KEY, model: m || PROVIDERS.openai.model, source: "OPENAI_API_KEY (environment)" };
@@ -48,6 +82,36 @@ function resolveProvider() {
48
82
  }
49
83
 
50
84
  // ---- model calls (one per provider, same in/out) --------------------------
85
+ // Claude Code, headless, on your subscription: Symbiot's own instructions as the
86
+ // system prompt, the prompt on stdin, no tools (Read only, for screenshots you
87
+ // attached), none of your settings, plugins or servers, no session kept. Not --bare:
88
+ // that ignores the subscription sign-in.
89
+ function callClaude(r, system, prompt, images = []) {
90
+ const shots = [];
91
+ for (const i of images) {
92
+ if (i.path) { shots.push(i.path); continue; }
93
+ try { const dir = join(CONFIG_DIR, "uploads"); mkdirSync(dir, { recursive: true, mode: 0o700 }); const f = join(dir, `${Date.now()}-${shots.length}.${/png/.test(i.mime) ? "png" : /webp/.test(i.mime) ? "webp" : /gif/.test(i.mime) ? "gif" : "jpg"}`); writeFileSync(f, Buffer.from(i.data, "base64"), { mode: 0o600 }); shots.push(f); } catch {}
94
+ }
95
+ const input = shots.length ? `${prompt}\n\nScreenshots they attached (look at them with your Read tool): ${shots.join(", ")}` : prompt;
96
+ const args = ["-p", "--output-format", "json", "--system-prompt", system, "--tools", shots.length ? "Read" : "", "--setting-sources", "",
97
+ "--strict-mcp-config", "--no-session-persistence", "--disable-slash-commands", ...(r.model ? ["--model", r.model] : [])];
98
+ return new Promise((resolve, reject) => {
99
+ let out = "", err = "", done = false;
100
+ let ch; try { ch = spawn(CLAUDE_BIN(), args, { cwd: CONFIG_DIR, stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { reject(e); return; }
101
+ const t = setTimeout(() => { if (!done) { done = true; try { ch.kill("SIGTERM"); } catch {} reject(new Error("Claude Code didn't answer within 3 minutes")); } }, 180000);
102
+ ch.stdout.on("data", (d) => { out += d; }); ch.stderr.on("data", (d) => { err += d; });
103
+ ch.on("error", (e) => { if (!done) { done = true; clearTimeout(t); reject(e); } });
104
+ ch.on("close", () => {
105
+ if (done) return; done = true; clearTimeout(t);
106
+ let j = null; try { j = JSON.parse(out.trim().split("\n").filter(Boolean).pop() || "{}"); } catch {}
107
+ if (j && j.is_error === false && typeof j.result === "string") { resolve(j.result.trim()); return; }
108
+ const why = (j && j.result) || err.trim().split("\n").pop() || out.trim().slice(0, 200) || "no answer";
109
+ CLAUDE_SEEN = null; // signed out since? ask again next time
110
+ reject(new Error(`Claude Code: ${String(why).slice(0, 300)}`));
111
+ });
112
+ ch.stdin.end(input);
113
+ });
114
+ }
51
115
  // Screenshots you dropped into the talk: [{ mime, data (base64) }], in each model's own shape.
52
116
  async function callAnthropic(r, system, prompt, images = []) {
53
117
  const client = new Anthropic(r.key ? { apiKey: r.key } : {});
@@ -111,7 +175,7 @@ async function callOllama(r, system, prompt, images = []) {
111
175
  // spinner, its coloured hints); imported on its own, it stays quiet.
112
176
  const AI_UI = {
113
177
  spinner: () => () => {},
114
- notConnected: () => console.log("Symbiot needs an AI to write your updates. Connect one with: symbiot login"),
178
+ 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"),
115
179
  rejected: (label) => console.log(`Your ${label} credentials were rejected. Reconnect with: symbiot login --force`),
116
180
  };
117
181
  async function write(system, prompt, { images = [] } = {}) {
@@ -119,6 +183,7 @@ async function write(system, prompt, { images = [] } = {}) {
119
183
  if (!r) { AI_UI.notConnected(); return null; }
120
184
  const stop = AI_UI.spinner("thinking…");
121
185
  try {
186
+ if (r.provider === "claude") return await callClaude(r, system, prompt, images);
122
187
  if (r.provider === "anthropic") return await callAnthropic(r, system, prompt, images);
123
188
  if (r.provider === "openai") return await callOpenAI(r, system, prompt, images);
124
189
  if (r.provider === "gemini") return await callGemini(r, system, prompt, images);
@@ -135,6 +200,7 @@ async function write(system, prompt, { images = [] } = {}) {
135
200
 
136
201
  async function validate(provider, { key, baseUrl } = {}) {
137
202
  try {
203
+ if (provider === "claude") return claudeReady(true);
138
204
  if (provider === "anthropic") { await new Anthropic({ apiKey: key }).models.list(); return true; }
139
205
  if (provider === "openai") return (await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${key}` } })).ok;
140
206
  if (provider === "gemini") return (await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`)).ok;
@@ -197,6 +263,12 @@ async function connectProvider(b) {
197
263
  const provider = b && b.provider;
198
264
  if (!PROVIDERS[provider]) return { ok: false, message: "Unknown provider." };
199
265
  const cfg = loadConfig();
266
+ if (provider === "claude") {
267
+ if (!claudeReady(true)) return { ok: false, message: claudeHelp(claudeState()) };
268
+ const model = (b.model || "").trim();
269
+ cfg.provider = "claude"; cfg.claude = model ? { model } : {};
270
+ return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS.claude.label}${model ? " · " + model : ""}. No key needed.` } : { ok: false, message: "Couldn't write the config file." };
271
+ }
200
272
  if (provider === "ollama") {
201
273
  const baseUrl = (b.baseUrl || "http://localhost:11434").trim();
202
274
  const model = (b.model || "").trim() || PROVIDERS.ollama.model;
@@ -212,4 +284,4 @@ async function connectProvider(b) {
212
284
  return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
213
285
  }
214
286
 
215
- export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel };
287
+ export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp };
package/away.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // Any key or click, or Super+` again, closes every window. Not a lock: Super+Esc
15
15
  // locks the computer.
16
16
  import { spawn, spawnSync } from "node:child_process";
17
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  import { CONFIG_DIR, chromeBinary } from "./core.mjs";
@@ -110,4 +110,14 @@ function installShortcut({ cmd, home = homedir(), desktop = process.env.XDG_CURR
110
110
  return { ok: true, file: f, replaced: /key:\s*"grave"/.test(had) };
111
111
  }
112
112
 
113
- export { placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
113
+ // Uninstalling: our Super+` line comes out of COSMIC's custom shortcuts; the file
114
+ // goes if nothing else was in it. True when there was one.
115
+ function removeShortcut(home = homedir()) {
116
+ const f = COSMIC_CUSTOM(home); let had = ""; try { had = readFileSync(f, "utf8"); } catch { return false; }
117
+ const lines = had.split("\n"), kept = lines.filter((l) => !/Spawn\("[^"]*symbiot[^"]* away"\)/.test(l));
118
+ if (kept.length === lines.length) return false;
119
+ const rest = kept.join("\n");
120
+ try { if (!rest.replace(/[{}\s]/g, "")) unlinkSync(f); else writeFileSync(f, rest); } catch {}
121
+ return true;
122
+ }
123
+ export { removeShortcut, placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
package/desktop.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // start-at-login launches that server in the background (no window) when you
5
5
  // log in, so the write-up still happens on days you never open Symbiot.
6
6
  // `symbiot app` then finds that running copy and opens its window.
7
+ import { fileURLToPath } from "node:url";
7
8
  import { spawn } from "node:child_process";
8
9
  import { homedir } from "node:os";
9
10
  import { join, dirname } from "node:path";
@@ -144,6 +145,52 @@ function autostartContent(node, script, platform = OS, path = process.env.PATH |
144
145
  return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "Comment=Symbiot in the background: the weekly write-up and its notification",
145
146
  `Exec=env SYMBIOT_NO_OPEN=1 ${q("PATH=" + path)} ${q(node)} ${q(script)} app`, "Terminal=false", "NoDisplay=true", "X-GNOME-Autostart-enabled=true", ""].join("\n");
146
147
  }
148
+ // ---- the app launcher ---------------------------------------------------------
149
+ // Symbiot in your app menu, with its icon: click it and Symbiot starts in the
150
+ // background (no terminal) and opens its window, or brings the window up if it's
151
+ // running (`symbiot open`). Linux (XDG): an entry in ~/.local/share/applications and
152
+ // the icon in your icon theme. `npm install -g` writes it (postinstall.mjs), and each
153
+ // start of an installed Symbiot writes it again if it's missing or out of date (a
154
+ // new Node, a moved install), so it never points at something gone.
155
+ const launcherFile = (home = homedir()) => join(home, ".local", "share", "applications", "symbiot.desktop");
156
+ const launcherIcon = (home = homedir()) => join(home, ".local", "share", "icons", "hicolor", "scalable", "apps", "symbiot.svg");
157
+ const ICON_SRC = fileURLToPath(new URL("./icon.svg", import.meta.url));
158
+ function launcherContent(node, script, path = process.env.PATH || "") {
159
+ const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
160
+ return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "GenericName=Assistant",
161
+ "Comment=Your work, your agents and what needs you, in one place",
162
+ `Exec=env ${q("PATH=" + path)} ${q(node)} ${q(script)} open`, "Icon=symbiot", "Terminal=false",
163
+ "Categories=Office;Utility;Development;", "Keywords=assistant;agents;tasks;week;standup;", "StartupNotify=true",
164
+ // the app's window is Chrome's --app window on Symbiot's address: this groups it under this icon
165
+ "StartupWMClass=chrome-127.0.0.1__-Default", ""].join("\n");
166
+ }
167
+ // The PATH the launcher starts it with: yours, without the throwaway folders npm adds
168
+ // while it installs (…/node_modules/.bin), each once, and Node's own folder in it.
169
+ function launcherPath(path, node) {
170
+ const seen = new Set(), out = [];
171
+ for (const d of [...String(path || "").split(":"), dirname(node)]) if (d && !/node_modules[\\/]\.bin|node-gyp-bin/.test(d) && !seen.has(d)) { seen.add(d); out.push(d); }
172
+ return out.join(":");
173
+ }
174
+ // Writes the entry and icon when they're missing or differ. { written, file } or { skipped }.
175
+ function installLauncher({ node = process.execPath, script, home = homedir(), platform = OS, path = process.env.PATH || "" } = {}) {
176
+ if (platform !== "linux" || !script) return { skipped: true };
177
+ const file = launcherFile(home), icon = launcherIcon(home), want = launcherContent(node, script, launcherPath(path, node));
178
+ let wrote = false;
179
+ try {
180
+ let had = ""; try { had = readFileSync(file, "utf8"); } catch {}
181
+ if (had !== want) { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, want, { mode: 0o644 }); wrote = true; }
182
+ let hadIcon = ""; try { hadIcon = readFileSync(icon, "utf8"); } catch {}
183
+ const svg = readFileSync(ICON_SRC, "utf8");
184
+ if (hadIcon !== svg) { mkdirSync(join(icon, ".."), { recursive: true }); writeFileSync(icon, svg, { mode: 0o644 }); wrote = true; }
185
+ } catch (e) { return { error: (e && e.message) || String(e) }; }
186
+ if (wrote) { try { spawn("update-desktop-database", [join(file, "..")], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {} }
187
+ return { written: wrote, file };
188
+ }
189
+ function removeLauncher(home = homedir()) {
190
+ const gone = [];
191
+ for (const f of [launcherFile(home), launcherIcon(home)]) { try { if (existsSync(f)) { unlinkSync(f); gone.push(f); } } catch {} }
192
+ return gone;
193
+ }
147
194
  function autostartState() { const file = autostartFile(); return { on: existsSync(file), file, ...(OS === "android-app" ? { phone: "app" } : OS === "android" ? { phone: "termux" } : {}) }; }
148
195
  // `script` is index.mjs's real path. From npx that's a cache folder that goes
149
196
  // away, so it has to be installed for this to keep working.
@@ -159,4 +206,4 @@ function setAutostart(on, script) {
159
206
  } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
160
207
  }
161
208
 
162
- export { weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
209
+ export { launcherPath, launcherFile, launcherIcon, launcherContent, installLauncher, removeLauncher, weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
package/home.mjs CHANGED
@@ -260,7 +260,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
260
260
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
261
261
  // only when it's missing, and then first (urgent): nothing works without it.
262
262
  // First run: what only a new user can do before the rest means anything.
263
- if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: a key, or a free local model", shape: "settings", focus: "ai" });
263
+ if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: your Claude subscription (sign in to Claude Code), a key, or a free local model", shape: "settings", focus: "ai" });
264
264
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
265
265
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
266
266
  if (!Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
@@ -408,7 +408,7 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
408
408
  const h = state || homeState({ now });
409
409
  let map = {}; try { map = laneMap(); } catch {}
410
410
  const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
411
- const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data }) => ({ mime, data })), ...(ask ? { ask } : {}),
411
+ const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
412
412
  act: {
413
413
  agent: (req, known, repo) => (repo ? actIn(req + shotNote, repo, { map, known, title: "Home" }) : actNow(req + shotNote, { title: "Home", known })),
414
414
  task: (text, repo) => taskIn(text + shotNote, repo, { map }),
package/icon.svg ADDED
@@ -0,0 +1,33 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <title>Symbiot</title>
3
+ <defs>
4
+ <radialGradient id="bg" cx="50%" cy="42%" r="70%">
5
+ <stop offset="0" stop-color="#1B1D22"/>
6
+ <stop offset="1" stop-color="#08090B"/>
7
+ </radialGradient>
8
+ <radialGradient id="orb" cx="40%" cy="34%" r="66%">
9
+ <stop offset="0" stop-color="#3A3D44"/>
10
+ <stop offset="0.45" stop-color="#121317"/>
11
+ <stop offset="1" stop-color="#030304"/>
12
+ </radialGradient>
13
+ <radialGradient id="rim" cx="50%" cy="50%" r="50%">
14
+ <stop offset="0.86" stop-color="#000" stop-opacity="0"/>
15
+ <stop offset="0.97" stop-color="#B0466E" stop-opacity="0.55"/>
16
+ <stop offset="1" stop-color="#F2A541" stop-opacity="0"/>
17
+ </radialGradient>
18
+ <radialGradient id="glow" cx="50%" cy="56%" r="50%">
19
+ <stop offset="0.55" stop-color="#F2A541" stop-opacity="0.16"/>
20
+ <stop offset="1" stop-color="#F2A541" stop-opacity="0"/>
21
+ </radialGradient>
22
+ <linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
23
+ <stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
24
+ <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
25
+ </linearGradient>
26
+ </defs>
27
+ <rect width="256" height="256" rx="58" fill="url(#bg)"/>
28
+ <circle cx="128" cy="136" r="96" fill="url(#glow)"/>
29
+ <circle cx="128" cy="128" r="78" fill="url(#orb)"/>
30
+ <circle cx="128" cy="128" r="78" fill="url(#rim)"/>
31
+ <ellipse cx="102" cy="92" rx="30" ry="17" transform="rotate(-28 102 92)" fill="url(#shine)"/>
32
+ <ellipse cx="160" cy="176" rx="22" ry="5" transform="rotate(-30 160 176)" fill="#FFFFFF" opacity="0.12"/>
33
+ </svg>
package/index.mjs CHANGED
@@ -23,16 +23,16 @@
23
23
  // tasks.mjs (tasks, review and Approve), agents.mjs (handing work to an agent),
24
24
  // drift.mjs, server.mjs (`symbiot app`'s server) and ui.mjs (its page).
25
25
 
26
- import { spawn } from "node:child_process";
26
+ import { spawn, spawnSync } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
28
  import { join, basename } from "node:path";
29
- import { realpathSync, readFileSync } from "node:fs";
29
+ import { realpathSync, readFileSync, mkdirSync, openSync, rmSync, existsSync } from "node:fs";
30
30
  import { createInterface } from "node:readline";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { EMBEDDED_UI } from "./ui.mjs";
33
- import { VERSION, CONFIG_PATH, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
33
+ import { VERSION, CONFIG_PATH, CONFIG_DIR, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
34
34
  import { HANDOFF_PROMPT, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, parseQuestions, agentQuestions } from "./agents.mjs";
35
- import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
35
+ import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel, claudeState, connectProvider } from "./ai.mjs";
36
36
  import { setScanOptions, scanBase, authorship, readmeInfo, repoShape, houseRules, reportFooter, findAllRepos, buildMap } from "./scan.mjs";
37
37
  import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.mjs";
38
38
  import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
@@ -42,7 +42,7 @@ import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrus
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
44
  import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
- import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
45
+ import { startApp, updateCmd, isAppRunningWeekly, askRunningApp, openApp } from "./server.mjs";
46
46
  import { listReports, readReport } from "./reports.mjs";
47
47
  import { runSandbox } from "./sandbox.mjs";
48
48
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, searchKnowledge, waitingOn, ownerOf, myName, setMyName, itemLine, caseLine } from "./knowledge.mjs";
@@ -118,7 +118,7 @@ function ask(question, { secret = false } = {}) {
118
118
 
119
119
  const AUTH_HELP =
120
120
  c.y("Symbiot needs an AI to write your updates. Connect one:\n") +
121
- " " + c.b("symbiot login") + c.d(" pick Claude, OpenAI, Gemini, or a local model (Ollama)") + "\n" +
121
+ " " + c.b("symbiot login") + c.d(" your Claude subscription (Claude Code, no key), Claude, OpenAI, Gemini or a local model") + "\n" +
122
122
  c.d(" Or set a key in your environment: ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.");
123
123
  // What writing with the AI (ai.mjs) shows in the terminal.
124
124
  Object.assign(AI_UI, {
@@ -215,26 +215,34 @@ async function cmdLogin() {
215
215
  const flagProvider = flag("provider", null);
216
216
  const existing = resolveProvider();
217
217
  if (existing && !flagProvider && !flag("key", null) && !has("force")) {
218
- console.log(c.g("✓ ") + `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`);
218
+ console.log(c.g("✓ ") + (existing.provider === "claude" ? "Already connected: your Claude subscription, through Claude Code signed in here. No key needed." : `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`));
219
219
  console.log(c.d(" Switch or replace it with `symbiot login --force`."));
220
220
  return;
221
221
  }
222
222
 
223
223
  let provider = flagProvider;
224
224
  if (!provider) {
225
+ const cs = claudeState(true);
225
226
  console.log("\n" + c.b("Connect Symbiot") + "\n");
226
227
  console.log("Which AI should Symbiot write your updates with?\n");
227
- console.log(" 1) " + PROVIDERS.anthropic.label + c.d(" — needs an Anthropic API key"));
228
- console.log(" 2) " + PROVIDERS.openai.label + c.d(" — needs an OpenAI API key"));
229
- console.log(" 3) " + PROVIDERS.gemini.label + c.d(" — needs a Google AI API key"));
230
- console.log(" 4) " + PROVIDERS.ollama.label + c.d(" — runs on your machine, no key"));
231
- const pick = (await ask("\nChoose 1-4 [1]: ")) || "1";
232
- provider = { 1: "anthropic", 2: "openai", 3: "gemini", 4: "ollama" }[pick] || (PROVIDERS[pick] ? pick : "anthropic");
228
+ console.log(" 1) " + PROVIDERS.claude.label + c.d(cs.signedIn ? " — signed in here, no key" : cs.installed ? " — Claude Code is here; sign in first (run claude )" : " — needs Claude Code, signed in (no key)"));
229
+ console.log(" 2) " + PROVIDERS.anthropic.label + c.d(" — needs an Anthropic API key"));
230
+ console.log(" 3) " + PROVIDERS.openai.label + c.d(" — needs an OpenAI API key"));
231
+ console.log(" 4) " + PROVIDERS.gemini.label + c.d(" — needs a Google AI API key"));
232
+ console.log(" 5) " + PROVIDERS.ollama.label + c.d(" — runs on your machine, no key"));
233
+ const pick = (await ask("\nChoose 1-5 [1]: ")) || "1";
234
+ provider = { 1: "claude", 2: "anthropic", 3: "openai", 4: "gemini", 5: "ollama" }[pick] || (PROVIDERS[pick] ? pick : "claude");
233
235
  }
234
236
  if (!PROVIDERS[provider]) { console.log(c.y("Unknown provider: " + provider)); return; }
235
237
  const meta = PROVIDERS[provider];
236
238
  const cfg = loadConfig();
237
239
 
240
+ if (provider === "claude") {
241
+ const r = await connectProvider({ provider: "claude", model: flag("model", null) || "" });
242
+ if (!r.ok) { console.log(c.y(r.message)); process.exitCode = 1; return; }
243
+ console.log(c.g("✓ ") + r.message + " Try: " + c.b("symbiot week"));
244
+ return;
245
+ }
238
246
  if (provider === "ollama") {
239
247
  const baseUrl = (flag("base-url", null) || (await ask("Ollama URL [http://localhost:11434]: ")) || "").trim() || "http://localhost:11434";
240
248
  const model = (flag("model", null) || (await ask(`Model name [${meta.model}]: `)) || "").trim() || meta.model;
@@ -342,6 +350,43 @@ async function cmdAway() {
342
350
  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; }
343
351
  if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
344
352
  }
353
+ // symbiot open: what the app-menu icon runs. Running: its window comes up. Not
354
+ // running: it starts in the background (no terminal; what it prints goes to
355
+ // ~/.config/symbiot/app.log) and opens its own window.
356
+ async function cmdOpen() {
357
+ const cfg = loadConfig(), port = Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391;
358
+ const p = cfg.appToken ? await askRunningApp("ping", { port, token: cfg.appToken, ms: 1500 }) : null;
359
+ if (p && p.version) { openApp(`http://127.0.0.1:${port}/?t=${cfg.appToken}`); return; }
360
+ let fd = "ignore"; try { mkdirSync(CONFIG_DIR, { recursive: true }); fd = openSync(join(CONFIG_DIR, "app.log"), "a"); } catch {}
361
+ const env = { ...process.env }; delete env.SYMBIOT_NO_OPEN;
362
+ spawn(process.execPath, [realpathSync(fileURLToPath(import.meta.url)), "app"], { detached: true, stdio: ["ignore", fd, fd], env }).unref();
363
+ }
364
+ // symbiot uninstall: everything Symbiot put on this computer, then the program.
365
+ // --keep-data keeps ~/.config/symbiot (settings, tasks, memory); --yes doesn't ask.
366
+ async function cmdUninstall() {
367
+ const keep = has("keep-data");
368
+ console.log(c.b("Remove Symbiot from this computer") + "\n");
369
+ console.log(" · its app-menu entry and icon, start at login, the Super+` shortcut");
370
+ console.log(keep ? " · " + c.d("keeps your data in " + CONFIG_DIR) : " · everything it knows: settings, tasks, memory, drafts, screenshots (" + CONFIG_DIR + ")");
371
+ if (!has("keep-program")) console.log(" · the program itself (npm uninstall -g symbiot)");
372
+ console.log(c.d(" Claude Code and its sign-in stay. Folders it made inside your projects (.symbiot/) stay too."));
373
+ if (!has("yes")) {
374
+ if (!process.stdin.isTTY) { console.log("\n" + c.y("Run it again with --yes to go ahead.")); process.exitCode = 1; return; }
375
+ if (((await ask("\nType yes to remove it: ")) || "").trim().toLowerCase() !== "yes") { console.log("Nothing removed."); return; }
376
+ }
377
+ const { removeLauncher, setAutostart } = await import("./desktop.mjs");
378
+ const { removeShortcut } = await import("./away.mjs");
379
+ await askRunningApp("quit", { ms: 2000 });
380
+ try { setAutostart(false); } catch {}
381
+ removeLauncher(); removeShortcut();
382
+ try { rmSync(join(homedir(), ".symbiot"), { recursive: true, force: true }); } catch {}
383
+ if (!keep) { try { rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {} }
384
+ console.log(c.g("✓ ") + "Removed what Symbiot added" + (keep ? ", your data kept." : ", and its data."));
385
+ if (has("keep-program")) return;
386
+ const r = spawnSync("npm", ["uninstall", "-g", "symbiot"], { stdio: "inherit", shell: process.platform === "win32" });
387
+ console.log(r.status === 0 ? c.g("✓ ") + "Symbiot is uninstalled." : c.y("Couldn't remove the program: run npm uninstall -g symbiot"));
388
+ if (existsSync(CONFIG_DIR) && !keep) console.log(c.d("(Something wrote to " + CONFIG_DIR + " meanwhile; delete it if you like.)"));
389
+ }
345
390
  async function cmdScreens() {
346
391
  const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
347
392
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
@@ -767,6 +812,9 @@ ${c.b("Usage")}
767
812
  symbiot standup yesterday + today, for standup
768
813
  symbiot todo what's still on your plate
769
814
  symbiot app open the visual app in your browser
815
+ symbiot open start Symbiot in the background and open its window
816
+ (what its app-menu icon does)
817
+ symbiot uninstall [--keep-data] remove Symbiot and everything it added
770
818
  symbiot away [--shortcut] Symbiot full screen on every screen while you're away;
771
819
  again closes it. --shortcut: Super+\` does it
772
820
  symbiot app --fresh [--keep] try it as someone new: a brand-new Symbiot in
@@ -825,6 +873,8 @@ async function main() {
825
873
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
826
874
  if (cmd === "app" || cmd === "ui") return cmdApp();
827
875
  if (cmd === "away") return cmdAway();
876
+ if (cmd === "open") return cmdOpen();
877
+ if (cmd === "uninstall") return cmdUninstall();
828
878
  if (cmd === "models" || cmd === "hardware") return cmdModels();
829
879
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
830
880
  if (cmd === "drift") return cmdDrift();
package/mind.mjs CHANGED
@@ -32,7 +32,7 @@ import { knowledgeFor } from "./knowledge.mjs";
32
32
  const MIND_FILE = join(CONFIG_DIR, "mind.json");
33
33
  const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
34
34
  const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
35
- const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG = 120, RECALL = 8, LOG_LINES = 6;
35
+ const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG = 240, RECALL = 8, LOG_LINES = 6, TURN_CHARS = 1500;
36
36
 
37
37
  // ---- memory ---------------------------------------------------------------------
38
38
  function loadMind() {
@@ -80,10 +80,19 @@ function recall(query, now = Date.now(), d = loadMind(), limit = RECALL) {
80
80
  }
81
81
  const recallText = (nodes) => nodes.map((n) => `- ${n.name} (${n.kind}): ${n.facts.slice(-4).map((f) => f.text).join("; ")}`).join("\n");
82
82
  function logTurn(where, role, text, now = Date.now(), d = loadMind()) {
83
- d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0, 400) }].slice(-MAX_LOG);
83
+ d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0, TURN_CHARS) }].slice(-MAX_LOG);
84
84
  }
85
85
  // The last few lines said anywhere else in the app: how the next page knows
86
86
  // what you were just talking about.
87
+ // This chat's own last turns, for a chat that doesn't keep its thread itself (Home):
88
+ // without them a "yeah" to "want that?" went to whatever was said elsewhere (a
89
+ // kernel reboot card) and the task they'd agreed to was never made. Home is where
90
+ // people think things through, so it keeps a real conversation: 16 turns, each whole
91
+ // (up to TURN_CHARS), from the last 12 hours.
92
+ const THREAD_TURNS = 16, THREAD_AGE = 12 * 3600000;
93
+ function ownThread(where, d = loadMind(), now = Date.now()) {
94
+ return d.log.filter((l) => l.where === where && now - (l.ts || 0) < THREAD_AGE).slice(-THREAD_TURNS).map((l) => `${l.role === "user" ? "User" : "You"}: ${l.text}`).join("\n");
95
+ }
87
96
  function lately(where, d = loadMind(), n = LOG_LINES) {
88
97
  return d.log.filter((l) => l.where !== where).slice(-n).map((l) => `- [${l.where}] ${l.role === "user" ? "User" : "You"}: ${l.text.slice(0, 200)}`).join("\n");
89
98
  }
@@ -156,6 +165,9 @@ function rulesFor(lanes = [], self = "") {
156
165
  When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, on the Workdesk, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
157
166
  Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
158
167
  Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
168
+ A short answer (yes, yeah, ok, sure, go, do it, that one, 2) answers what you last said in "This chat so far", never something from elsewhere in the app. If you'd proposed a task or an agent and they agree, do it now in "do", in the lane you named.
169
+ Hash it out first: when they bring an idea rather than a clear ask, sharpen it with them: say what's strong, push back where it's weak, add what's missing, and propose it as a task (naming the lane) once it holds up. "do" stays null until they agree.
170
+ When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through.
159
171
  "remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
160
172
  }
161
173
  // Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
@@ -193,6 +205,7 @@ function parseReply(raw) {
193
205
  // what "do" runs here. Gives { reply, did?, remembered }.
194
206
  async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
195
207
  const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
208
+ if (!history) history = ownThread(where, d, now);
196
209
  // what it did to answer, to show under the reply (as the app shows an agent's work)
197
210
  const steps = [];
198
211
  if (hits.length) steps.push(`recalled ${hits.length === 1 ? hits[0].name : hits.length + " things: " + hits.slice(0, 3).map((h) => h.name).join(", ")}`);
@@ -205,7 +218,7 @@ async function converse({ where, role = "", context = "", history = "", question
205
218
  // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
206
219
  const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
207
220
  const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
208
- 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:\n${elsewhere}\n\n` : "") +
221
+ 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` : "") +
209
222
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
210
223
  const raw = await ask(system, prompt, images.length ? { images } : undefined);
211
224
  if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
@@ -237,4 +250,4 @@ async function converse({ where, role = "", context = "", history = "", question
237
250
  // The usual "task" act, for a caller with no lanes of its own to pass.
238
251
  const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
239
252
 
240
- export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
253
+ export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks , ownThread };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.55.2",
3
+ "version": "0.56.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": {
@@ -44,7 +44,9 @@
44
44
  "mapknn.mjs",
45
45
  "guard.mjs",
46
46
  "away.mjs",
47
- "marketing.mjs"
47
+ "marketing.mjs",
48
+ "postinstall.mjs",
49
+ "icon.svg"
48
50
  ],
49
51
  "engines": {
50
52
  "node": ">=18"
@@ -77,8 +79,9 @@
77
79
  "@anthropic-ai/sdk": "^0.128.0"
78
80
  },
79
81
  "scripts": {
80
- "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/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
81
- "lint": "eslint ."
82
+ "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/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
83
+ "lint": "eslint .",
84
+ "postinstall": "node postinstall.mjs"
82
85
  },
83
86
  "devDependencies": {
84
87
  "eslint": "^9.39.5",
@@ -0,0 +1,12 @@
1
+ // npm install -g symbiot runs this: Symbiot in your app menu, with its icon, so you
2
+ // never need a terminal to start it (desktop.mjs installLauncher). Only for a global
3
+ // install, and it never fails the install.
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ try {
7
+ if (process.env.npm_config_global === "true" && !process.env.SYMBIOT_NO_LAUNCHER) {
8
+ const { installLauncher } = await import("./desktop.mjs");
9
+ const r = installLauncher({ script: realpathSync(fileURLToPath(new URL("./index.mjs", import.meta.url))) });
10
+ if (r.file) console.log("Symbiot is in your app menu: open it from there, no terminal needed.");
11
+ }
12
+ } catch {}
package/server.mjs CHANGED
@@ -22,7 +22,7 @@ import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, push
22
22
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
23
23
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
24
24
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
25
- import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
25
+ import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher } from "./desktop.mjs";
26
26
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
27
27
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
28
28
  import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
@@ -204,7 +204,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
204
204
  if (p && p.version) {
205
205
  const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
206
206
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
207
- console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
207
+ console.log(how ? c.d(` Opened the existing window (${/^[aeiou]/.test(how) ? "an" : "a"} ${how}).`) : c.d(" Open that URL in your browser."));
208
208
  console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
209
209
  return;
210
210
  }
@@ -214,6 +214,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
214
214
  keepBrowserOpen(BROWSER_KEEP);
215
215
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
216
216
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
217
+ // an installed Symbiot keeps its app-menu entry pointing at itself (a new Node, a moved
218
+ // install); not a copy run from a checkout or a test
219
+ if (/[\\/]node_modules[\\/]symbiot[\\/]/.test(bin || "") && !SANDBOX) { try { installLauncher({ script: bin }); } catch {} }
217
220
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
218
221
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
219
222
  process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
@@ -221,6 +224,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
221
224
  const u = new URL(req.url, "http://127.0.0.1");
222
225
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
223
226
  // the app's typeface ships in the package (fonts/), so it's there offline
227
+ // the window's own icon (its taskbar entry), the same orb as the app menu's
228
+ if (req.method === "GET" && (u.pathname === "/favicon.svg" || u.pathname === "/favicon.ico")) {
229
+ try { const b = readFileSync(fileURLToPath(new URL("./icon.svg", import.meta.url))); res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }); res.end(b); }
230
+ catch { res.writeHead(404); res.end(); }
231
+ return;
232
+ }
224
233
  if (req.method === "GET" && u.pathname === "/fonts/Geist-Variable.woff2") {
225
234
  try { const b = readFileSync(fileURLToPath(new URL("./fonts/Geist-Variable.woff2", import.meta.url))); res.writeHead(200, { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" }); res.end(b); }
226
235
  catch { res.writeHead(404); res.end(); }
@@ -231,7 +240,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
231
240
  if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
232
241
  }
233
242
  try {
234
- if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
243
+ if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, provider: r.provider, line: `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` } : { connected: false }); }
235
244
  if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
236
245
  if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
237
246
  if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
@@ -477,7 +486,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
477
486
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
478
487
  const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
479
488
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
480
- console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
489
+ console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in ${/^[aeiou]/.test(how) ? "an" : "a"} ${how}.`) : c.d(" Open that URL in your browser."));
481
490
  console.log(c.d(" Leave this running; press Ctrl+C to stop (or click the X in the window's top corner)."));
482
491
  });
483
492
  server.on("error", (e) => {
@@ -508,4 +517,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
508
517
  setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
509
518
  }
510
519
 
511
- export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
520
+ export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
package/ui.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  // Kept apart from the CLI/server so UI edits can't break the backend (and vice
6
6
  // versa); test/smoke.mjs boots the real page and exercises every handler.
7
7
  export const EMBEDDED_UI = `<!doctype html><html><head><meta charset="utf8">
8
- <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" id="themecolor" content="#08090B"><meta name="color-scheme" content="dark light"><title>Symbiot</title>
8
+ <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" id="themecolor" content="#08090B">
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg"><meta name="color-scheme" content="dark light"><title>Symbiot</title>
9
10
  <style>
10
11
  @font-face{font-family:'Geist';src:url('/fonts/Geist-Variable.woff2') format('woff2');font-weight:100 900;font-style:normal;font-display:swap}
11
12
  :root{--ink:#0E1A1F;--ink2:#15262C;--ink3:#1D333A;--line:#24404A;--bone:#F4F1EA;--text:#B7C9C4;--faint:#7E9690;--green:#3DDC97;--amber:#F2A541;--green-dim:#16322D;--sans:'Geist',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
@@ -1078,12 +1079,14 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1078
1079
  <div class="sset"><h3 class="ssh">Your AI</h3>
1079
1080
  <label>Which AI should Symbiot write with?</label>
1080
1081
  <select id="provider">
1081
- <option value="anthropic">Claude (Anthropic)</option>
1082
+ <option value="claude">Your Claude subscription (Claude Code) - no key</option>
1083
+ <option value="anthropic">Claude (Anthropic) - API key</option>
1082
1084
  <option value="openai">OpenAI (GPT)</option>
1083
1085
  <option value="gemini">Gemini (Google)</option>
1084
1086
  <option value="ollama">Local model (Ollama) - free, no key</option>
1085
1087
  </select>
1086
- <div class="note muted">The Map needs no key. For the write-ups, a hosted model needs an API key - or run <b>Ollama</b> locally for a free, private option (nothing leaves your machine).</div>
1088
+ <div class="note muted">With a Claude subscription (Pro or Max), Symbiot answers through <b>Claude Code</b> signed in on this computer: no key, no extra bill. Otherwise a hosted model needs an API key, or run <b>Ollama</b> locally for a free, private option (nothing leaves your machine).</div>
1089
+ <div id="subWrap" class="note hidden"></div>
1087
1090
  <div id="keyWrap"><label>API key</label><input id="key" type="password" placeholder="paste your key">
1088
1091
  <div class="note"><a id="getkey">Where do I get a key?</a></div></div>
1089
1092
  <div id="baseWrap" class="hidden"><label>Ollama URL</label><input id="baseUrl" type="text" value="http://localhost:11434"></div>
@@ -1174,8 +1177,8 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1174
1177
  <script>
1175
1178
  var T=new URLSearchParams(window.location.search).get('t')||'';
1176
1179
  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();});}
1177
- var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
1178
- var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1180
+ var KEYURL={claude:'https://claude.com/claude-code',anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
1181
+ var DEFMODEL={claude:'your plan’s default',anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1179
1182
  function $(id){return document.getElementById(id);}
1180
1183
  // Live updates never pull text out from under your selection. A refresh that would
1181
1184
  // redraw what you're selecting in waits until you let it go (selWait), and a redraw
@@ -1233,7 +1236,8 @@ function firstTab(){api('/api/watch/board').then(function(b){if(b&&b.cards&&b.ca
1233
1236
  // The AI in use isn't shown while it works (it's noise); only when none is
1234
1237
  // connected, as an urgent line that opens its part of Settings. Home has it out
1235
1238
  // front too, with the agent and the connectors runs use (home.mjs).
1236
- function refresh(){api('/api/status').then(function(s){var st=$('status');if(!st)return;st.classList.toggle('hidden',!!s.connected);st.classList.toggle('urgent',!s.connected);st.textContent=s.connected?'':"No AI connected: Symbiot can't work. Connect one \\u203a";});}
1239
+ var SUBLINE='';
1240
+ function refresh(){api('/api/status').then(function(s){if(s&&s.connected&&s.provider){var pv=$('provider');if(pv&&!pv.getAttribute('data-touched')){pv.value=s.provider;SUBLINE=s.provider==='claude'?'Connected: '+s.line+'. No key needed.':'';syncP();}}var st=$('status');if(!st)return;st.classList.toggle('hidden',!!s.connected);st.classList.toggle('urgent',!s.connected);st.textContent=s.connected?'':"No AI connected: Symbiot can't work. Connect one \\u203a";});}
1237
1241
  // the status opens Settings: on a phone its tab is scrolled out of sight
1238
1242
  $('status').addEventListener('click',function(){var b=document.body;if(b&&b.classList&&b.classList.contains('lq-liquid')&&typeof lqFocus==='function'){lqFocus({kind:'setup',focus:'ai'});return;}setTab('settings');var t=document.querySelector('.tab[data-tab=settings]');if(t&&t.scrollIntoView)t.scrollIntoView({block:'nearest',inline:'nearest'});});
1239
1243
  // Where you are: the section you opened, named at the top of its panel (and the
@@ -1261,9 +1265,9 @@ api('/api/run',{cmd:current}).then(function(r){var f=$('outfoot');if(r.error==='
1261
1265
  var foot=[r.footer,r.file?'Saved to '+r.file:'',r.error&&r.text&&r.error!==r.text?r.error:''].filter(Boolean).join(' · ');
1262
1266
  WUTEXT=r.text||'';$('out').innerHTML=r.text?mdLite(r.text):'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');if(foot){f.textContent=foot;f.style.display='block';}else{f.style.display='none';}});});
1263
1267
  $('copy').addEventListener('click',function(){var c=$('copy');try{navigator.clipboard.writeText(WUTEXT||$('out').textContent);}catch(e){}c.setAttribute('title','Copied');c.classList.add('done');setTimeout(function(){c.setAttribute('title','Copy');c.classList.remove('done');},1400);});
1264
- function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
1268
+ function syncP(){var p=$('provider').value;var local=p==='ollama',sub=p==='claude';$('keyWrap').classList.toggle('hidden',local||sub);$('baseWrap').classList.toggle('hidden',!local);var sw=$('subWrap');if(sw){sw.classList.toggle('hidden',!sub);sw.textContent=sub?(SUBLINE||'Uses Claude Code signed in on this computer with your Claude account. Not signed in yet? Run claude once in a terminal and sign in, then Save.'):'';}
1265
1269
  $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
1266
- $('provider').addEventListener('change',syncP);
1270
+ $('provider').addEventListener('change',function(){$('provider').setAttribute('data-touched','1');syncP();});
1267
1271
  $('getkey').addEventListener('click',function(){window.open(KEYURL[$('provider').value],'_blank');});
1268
1272
  $('save').addEventListener('click',function(){var p=$('provider').value;$('saveMsg').textContent='checking...';$('saveMsg').className='note muted';
1269
1273
  var cfg={provider:p,model:$('model').value.trim()};if(p==='ollama')cfg.baseUrl=$('baseUrl').value.trim();else cfg.key=$('key').value.trim();