symbiot 0.40.5 → 0.41.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/README.md CHANGED
@@ -20,8 +20,9 @@ symbiot logout forget saved credentials
20
20
  symbiot help
21
21
  ```
22
22
 
23
- A few newer extras (email, CI checks, local-model setup) are
24
- [experimental](#experimental).
23
+ The newer extras are [experimental](#experimental): email, CI checks,
24
+ local-model setup, Screens and Watch, and Symbiot on a phone. The list there says
25
+ what's stable and what isn't.
25
26
 
26
27
  ## Install
27
28
 
@@ -114,7 +115,8 @@ repo is now archived), without Electron:
114
115
 
115
116
  ### On your phone (Android, in Termux)
116
117
 
117
- Experimental, and not yet tried on a real phone. Symbiot is a Node CLI, so it runs
118
+ Experimental, and tried on one phone so far (a Galaxy A26, Android 16), where it
119
+ found the projects in Termux's home and in a Debian `proot-distro`. Symbiot is a Node CLI, so it runs
118
120
  in [Termux](https://termux.dev) (install it from F-Droid; the Play Store build is
119
121
  out of date) next to the coding agent you use there. It works on the repos on your
120
122
  phone, the same way it works on your computer's:
@@ -228,24 +230,36 @@ your review** at the top of the Tasks tab, grouped by repo, with the branch, the
228
230
  size of the uncommitted change and a **Show diff** button. For each repo:
229
231
 
230
232
  - **Approve → PR** syncs the work. If you're on the default branch it creates
231
- `symbiot/<task>`, commits everything except `.symbiot/` with the approved tasks as
232
- the message, pushes, and opens a PR with the GitHub CLI (`gh`). If you're already
233
- on a feature branch it commits and opens the PR from there. Each step that can't
233
+ `symbiot/<task>`, commits everything except `.symbiot/`, pushes, and opens a PR
234
+ with the GitHub CLI (`gh`). The commit's subject (and the PR's title) is the
235
+ first approved task, with how many more there are ("Watch GitHub too (+2
236
+ more)"), and its message lists them all, so `git log` reads as a history. If
237
+ you're already on a feature branch it commits and opens the PR from there,
238
+ unless that branch's earlier PR was already squash-merged: then it starts a
239
+ fresh `symbiot/` branch from the default one, so the new PR can merge. Each step that can't
234
240
  happen stops there and says why: no `origin` remote means a local commit only, and
235
241
  without `gh` it pushes and stops. Your commit is never lost. Then the tasks are
236
242
  archived with their commit and PR link. With no changes to commit the button
237
243
  reads **Approve → archive** and just archives them.
238
244
  - **↩ (send back)** is for one that isn't right: it reopens the task and unticks it in
239
245
  `TASKS.md`, so the next **Send to repos** hands it to the agent again.
246
+ - **Auto-merge when CI passes** (a switch on the card, per repo, off by default)
247
+ queues GitHub's own auto-merge on the PR Approve opens, so it merges once its
248
+ required checks pass. It needs "Allow auto-merge" in the repo's GitHub
249
+ settings; without it, the card says so.
240
250
 
241
- Tasks waiting for review aren't re-sent to the agent.
251
+ Tasks waiting for review aren't re-sent to the agent, and while the repo's agent
252
+ is still running, Approve waits ("agent still working"), so half-done work isn't
253
+ committed.
242
254
 
243
- **Releases.** For a repo that releases with `v*` tags, the review card warns when
244
- the default branch is past its last tag (merged work that isn't released). If
245
- `package.json`'s version is already released and the changes don't touch it, a
246
- **Version** picker next to Approve bumps it in the same PR: a patch by default, or a
247
- minor, or keep it. It updates `package.json` and the lockfile's own version, and the
248
- PR says which tag to push once it merges.
255
+ **Releases.** The review card warns about merged work that isn't released yet,
256
+ measured the way the repo releases: from its last `v*` tag, or, for a repo whose
257
+ GitHub workflow runs `npm publish` on every push to its default branch, from the
258
+ version npm has. If `package.json`'s version is already released and the changes
259
+ don't touch it, a **Version** picker next to Approve bumps it in the same PR: a
260
+ patch by default, or a minor, or keep it. It updates `package.json` and the
261
+ lockfile's own version, and the PR says what happens next: it publishes when it
262
+ merges, or which tag to push once it does.
249
263
 
250
264
  If a repo you sent tasks to has uncommitted changes but no ticked task (say the
251
265
  agent made a fix and didn't tick anything), it still shows up here, with
@@ -317,18 +331,25 @@ Codex, Aider, Gemini, Cursor, whatever you run) a way to ask anyway. It writes
317
331
  ## Questions
318
332
  ### Keep the old config format working?
319
333
  Reading both costs about 40 lines.
320
- - Yes, read both (recommended)
321
- - No, migrate once and drop it
334
+ - 🤖 Agent: read both (recommended)
335
+ - 👤 You: migrate your config once, then the agent drops the old format
322
336
 
323
337
  ## Suggestions
324
338
  - Add a --json flag to drift
339
+ - [repo: symbiot] Show the drift report in Standup
325
340
  ```
326
341
 
327
342
  Up to five questions show on that agent's block in the **Agents** tab, each with
328
- its options and room for your own answer, alongside its ideas. **Send answers &
343
+ its options and room for your own answer, alongside its ideas. Each option says
344
+ who acts: **You** (a setting, a click, a command) or **Agent** (picking it is
345
+ enough). A question that needs a release ("Once 0.41.0 is installed: …") shows
346
+ the version installed here and the one on npm, and holds back a "Done" answer
347
+ until that release is out and installed. **Send answers &
329
348
  continue** appends your answers to `.symbiot/ANSWERS.md` and runs your agent
330
349
  command again so the agent carries on with them. **Save only** keeps them for the
331
- next run. **+ task** adds an idea to your Tasks for that repo. The agent is told to
350
+ next run. **+ task** adds an idea to your Tasks for that repo, or, for an idea
351
+ that starts with `[repo: <name>]`, to that repo's Tasks: an agent working on one
352
+ project can have ideas for another (Symbiot itself, say). The agent is told to
332
353
  keep going with everything that doesn't depend on an answer, and to ask instead of
333
354
  doing anything destructive. Both files live in `.symbiot/`, so they're never
334
355
  committed.
@@ -431,19 +452,50 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
431
452
  - **Sends to the AI:** commit messages and dates, changed-file **names**, the
432
453
  folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
433
454
  the README and any `CLAUDE.md`/`AGENTS.md` conventions. With email on, it also
434
- sends the subjects and recipient names of mail you sent. It does **not** send
435
- whole source files or any email body. With a **local Ollama model, nothing leaves your machine.**
455
+ sends the subjects and recipient names of mail you sent. When you ask about a
456
+ task (💬) the agent has finished, it sends the first 6 KB of the agent's diff,
457
+ which can include a new file's contents. Standup sends up to 10 of what's new
458
+ on each page you [Watch](#screens-blueprints-for-screen-automation) since
459
+ yesterday, and the brief (if you switch it on) sends each batch of it: for
460
+ Gmail, that's the sender, subject and one-line preview. Otherwise it does
461
+ **not** send source files, and it never sends a whole email body. With a **local Ollama model, nothing leaves your machine.**
436
462
  - **Shows its work:** every report ends with a footer — path, branch, how many
437
463
  commits matched you (e.g. "1090 of 1101"), the README's age, and the
438
464
  working-tree state — so you can see exactly what it read.
439
- - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
465
+ - **Never:** no keylogging, no browsing history, no accounts. The
466
+ [experimental](#screens-blueprints-for-screen-automation) Screens takes a
467
+ screenshot only when you click Capture, and opens only the web pages you map
468
+ or Watch (in a browser profile of its own). What it maps and what Watch finds
469
+ stay on your computer, unless you pair your phone (Watch on your phone) or
470
+ switch on the brief, which sends what's new to your AI.
440
471
  - **Runs only what you set:** the agent command is yours, and deploy commands are
441
472
  read only from your own `~/.config/symbiot/`, never from a repo.
442
473
 
443
474
  ## Experimental
444
475
 
445
- These work and are tested, but are newer or depend on things Symbiot can't fully
446
- check, so they may change. Everything above works without them.
476
+ **Stable:** `week`, `standup`, `todo`; the app's Map, Drift, Tasks, Agents and
477
+ Settings; folders to scan (including project folders without git); `push` and
478
+ the agent handoff; agent questions; review, Approve, the version bump and
479
+ auto-merge; `drift` from local git facts; the weekly write-up and start at
480
+ login. The test suite covers each of them.
481
+
482
+ **Experimental:** the rest, below, and Symbiot on a phone ([Termux](#on-your-phone-android-in-termux)
483
+ and the [Android app](#the-android-app-apk)). These work and are tested, but are
484
+ newer, have only been tried on one or two setups, or depend on things Symbiot
485
+ can't fully check, so they may change. Everything above works without them.
486
+
487
+ - **Email** (`symbiot mail`): tested on mbox and Maildir, not yet on real Apple
488
+ Mail, Evolution or KMail stores.
489
+ - **CI status** (`drift --ci`): GitHub Actions only, and "not running" is a guess.
490
+ - **Local models** (`models`, `setup-local`).
491
+ - **Screens**: Capture depends on your desktop allowing screenshots; Click here
492
+ hasn't been confirmed on Wayland (ydotool) or on Windows yet; Map page, Press,
493
+ Type and Trusted sites work on web pages only.
494
+ - **Watch**: reads your Gmail inbox, but hasn't yet been seen to notify a new
495
+ email on a real one. GitHub through `gh` has been read from a real account,
496
+ but not yet left running. The brief, Standup's "Waiting on you" line and
497
+ **Watch on your phone** are new; the phone side hasn't been tried on a real
498
+ phone yet.
447
499
 
448
500
  ### Your sent email, without an API: `symbiot mail`
449
501
 
@@ -471,6 +523,9 @@ real Apple Mail, Evolution or KMail stores. On macOS, reading Apple Mail needs F
471
523
  Disk Access for your terminal. Outlook for Windows (`.pst`) isn't supported:
472
524
  export to `.mbox` or use Thunderbird.
473
525
 
526
+ This is mail you **sent**, for write-ups. To be told about **new mail in your
527
+ inbox**, map it in Screens and [Watch](#screens-blueprints-for-screen-automation) it.
528
+
474
529
  ### CI status: `symbiot drift --ci`
475
530
 
476
531
  ```bash
@@ -575,12 +630,53 @@ Your coding agent reads it too, so it can act on it with `symbiot screens`:
575
630
 
576
631
  ```bash
577
632
  symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot watch add gmail)
633
+ symbiot watch add github # your GitHub notifications (see below)
578
634
  symbiot watch new --hours 24 # what's new, newest first (JSON)
579
635
  symbiot watch check # read them all now (JSON)
636
+ symbiot watch brief on # your AI says what needs you (off to stop)
580
637
  symbiot watch # what you watch, and what's new
581
638
  symbiot watch remove <id>
582
639
  ```
583
640
 
641
+ **GitHub too.** **Watch GitHub** (under Watching) or `symbiot watch add github`
642
+ watches your GitHub notifications: review requests, failed CI runs, mentions,
643
+ assignments. With the GitHub CLI signed in (`gh auth login`), Symbiot reads them
644
+ through it (GitHub's API, your unread notifications), so nothing needs mapping or
645
+ signing in. Without `gh`, it reads `github.com/notifications` in the hidden browser
646
+ like any page (click **Sign in** with `github.com` once). Each notification is new
647
+ when its thread changes, so a CI run that fails again on the same branch, or a new
648
+ comment on a pull request, notifies you again. Failed CI runs reach you only if
649
+ GitHub notifies you about them (GitHub → Settings → Notifications → Actions).
650
+
651
+ **The brief.** Tick **Brief me** under Watching (or `symbiot watch brief on`) and
652
+ the AI you connected reads each batch of what's new and says, in a line or three,
653
+ what needs you and what can wait. The brief shows above what's new, and it's the
654
+ text of the notification. The AI is sent what the page lists for each new item
655
+ (for Gmail, the sender, subject and the one-line preview), and nothing at all
656
+ leaves your computer with a local Ollama model. It's off until you tick it.
657
+
658
+ **In Standup.** Standup ends with what's waiting on you since yesterday, counted
659
+ from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbiot
660
+ counts them itself, so the numbers are right; the AI only sees them to know
661
+ what's next.
662
+
663
+ **On your phone.** What Watch finds on your computer can show up as a
664
+ notification on your phone, through Symbiot there (the [Android app](#the-android-app-apk),
665
+ or Symbiot in [Termux](#on-your-phone-android-in-termux)). On the computer, tick
666
+ **Watch on your phone** in Settings: it shows the computer's address and a 6-digit
667
+ code, good for 10 minutes. On the phone, in Settings → **Watch on your phone**,
668
+ type both and click **Pair**. From then on, while Symbiot runs on both, the phone
669
+ asks the computer every 2 minutes and notifies what's new there, with its brief.
670
+ They have to be on the same network (or both on a VPN such as Tailscale, whose
671
+ address is listed too). For this, the computer listens on port 7392 of your
672
+ network, and serves only two things there: pairing with that code (5 wrong tries
673
+ and the code is gone), and what's new for a phone that paired (with a token of its
674
+ own; unpair it in Settings). Nothing can be changed from there, and the rest of
675
+ Symbiot stays on `127.0.0.1`. What's new crosses your network unencrypted, so
676
+ switch it on at home, not on a café's Wi-Fi. A firewall on the computer may need
677
+ to allow the port. In Termux, notifications need the Termux:API app and
678
+ `pkg install termux-api`.
679
+
584
680
  **More than one display?** Symbiot reads how your displays are laid out
585
681
  (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
586
682
  Windows, AppKit on macOS) and a picker appears next to Capture: **each display**
package/agents.mjs CHANGED
@@ -305,8 +305,17 @@ function parseQuestions(md) {
305
305
  }
306
306
  return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
307
307
  }
308
- // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
309
- // if it's already on the task list for this repo.
308
+ // An idea for another project names it first: "[repo: symbiot] Watch GitHub
309
+ // too" is for the symbiot repo's tasks, whichever repo's agent had it (an agent
310
+ // working on coral may have ideas for Symbiot, the app that sent it). Gives
311
+ // { repo, text }, repo "" when the idea doesn't name one.
312
+ function suggestionTarget(s) {
313
+ const m = String(s || "").match(/^\[repo:\s*([^\]]*?)\s*\]\s*(.+)$/i);
314
+ return m && m[1] ? { repo: m[1], text: m[2].trim() } : { repo: "", text: String(s || "") };
315
+ }
316
+ // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each with
317
+ // the repo whose tasks it goes to (this one unless it names another) and marked
318
+ // if it's already on that repo's task list.
310
319
  function agentQuestions(path, repo) {
311
320
  const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
312
321
  const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
@@ -314,7 +323,7 @@ function agentQuestions(path, repo) {
314
323
  const tasks = p.suggestions.length ? loadTasks() : [];
315
324
  return {
316
325
  questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
317
- suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
326
+ suggestions: p.suggestions.map(suggestionTarget).map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && qKey(t.text) === qKey(text)) })),
318
327
  };
319
328
  }
320
329
  // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
@@ -353,4 +362,4 @@ function agentsList() {
353
362
  });
354
363
  }
355
364
 
356
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
365
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
package/ai.mjs ADDED
@@ -0,0 +1,213 @@
1
+ // The AI Symbiot writes with: the providers, which one is set up, one call per
2
+ // provider (same in, same out), and the local-model (Ollama) setup.
3
+ import Anthropic from "@anthropic-ai/sdk";
4
+ import { spawn } from "node:child_process";
5
+ import { homedir, totalmem, cpus as oscpus } from "node:os";
6
+ import { join } from "node:path";
7
+ import { existsSync } from "node:fs";
8
+ import { loadConfig, saveConfig, sh } from "./core.mjs";
9
+
10
+ const MAX_TOKENS = 1600;
11
+
12
+ // The providers Symbiot can write with. Models are sensible defaults; override
13
+ // per provider at login, or globally with SYMBIOT_MODEL.
14
+ const PROVIDERS = {
15
+ 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
+ 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
+ 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
+ ollama: { label: "Local model (Ollama)", local: true, keyUrl: "https://ollama.com", keyName: null, model: "llama3.1" },
19
+ };
20
+
21
+ // ---- config + provider resolution (config files: core.mjs) -----------------
22
+ function antProfileExists() {
23
+ try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
24
+ }
25
+
26
+ function envKey(provider) {
27
+ for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
28
+ return null;
29
+ }
30
+ // Returns { provider, key?, baseUrl?, model, source } or null if nothing set up.
31
+ // Order: saved choice → legacy saved key → env keys → an `ant` profile.
32
+ function resolveProvider() {
33
+ const cfg = loadConfig();
34
+ const m = process.env.SYMBIOT_MODEL;
35
+ if (cfg.provider && PROVIDERS[cfg.provider]) {
36
+ const p = cfg.provider, pc = cfg[p] || {};
37
+ if (p === "ollama") return { provider: p, baseUrl: pc.baseUrl || "http://localhost:11434", model: m || pc.model || PROVIDERS.ollama.model, source: "saved login" };
38
+ const key = pc.apiKey || envKey(p);
39
+ if (key || (p === "anthropic" && (process.env.ANTHROPIC_AUTH_TOKEN || antProfileExists())))
40
+ return { provider: p, key, model: m || pc.model || PROVIDERS[p].model, source: pc.apiKey ? "saved login" : "environment" };
41
+ }
42
+ if (cfg.apiKey) return { provider: "anthropic", key: cfg.apiKey, model: m || PROVIDERS.anthropic.model, source: "saved login (~/.config/symbiot)" };
43
+ 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
+ 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)" };
45
+ if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return { provider: "gemini", key: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY, model: m || PROVIDERS.gemini.model, source: "GEMINI/GOOGLE_API_KEY (environment)" };
46
+ if (antProfileExists()) return { provider: "anthropic", model: m || PROVIDERS.anthropic.model, source: "Anthropic CLI profile (ant auth login)" };
47
+ return null;
48
+ }
49
+
50
+ // ---- model calls (one per provider, same in/out) --------------------------
51
+ async function callAnthropic(r, system, prompt) {
52
+ const client = new Anthropic(r.key ? { apiKey: r.key } : {});
53
+ const base = { model: r.model, max_tokens: MAX_TOKENS, system, messages: [{ role: "user", content: prompt }] };
54
+ let res;
55
+ try { res = await client.messages.create({ ...base, output_config: { effort: "low" } }); }
56
+ catch (e) {
57
+ // effort/output_config isn't accepted on every model (e.g. Haiku) — retry plain
58
+ if (/effort|output_config|thinking|budget|400/i.test(e?.message || "")) res = await client.messages.create(base);
59
+ else throw e;
60
+ }
61
+ if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
62
+ return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
63
+ }
64
+ async function callOpenAI(r, system, prompt) {
65
+ const messages = [{ role: "system", content: system }, { role: "user", content: prompt }];
66
+ // Newer models want max_completion_tokens instead of max_tokens; try both.
67
+ for (const tokKey of ["max_tokens", "max_completion_tokens"]) {
68
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
69
+ method: "POST",
70
+ headers: { Authorization: `Bearer ${r.key}`, "Content-Type": "application/json" },
71
+ body: JSON.stringify({ model: r.model, [tokKey]: MAX_TOKENS, messages }),
72
+ });
73
+ const text = await res.text();
74
+ if (res.ok) return (JSON.parse(text).choices?.[0]?.message?.content || "").trim();
75
+ if (res.status === 400 && tokKey === "max_tokens" && /max_tokens|max_completion_tokens/i.test(text)) continue;
76
+ throw new Error(`OpenAI ${res.status}: ${text.slice(0, 200)}`);
77
+ }
78
+ return "";
79
+ }
80
+ async function callGemini(r, system, prompt) {
81
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(r.model)}:generateContent?key=${encodeURIComponent(r.key)}`;
82
+ const res = await fetch(url, {
83
+ method: "POST", headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({
85
+ systemInstruction: { parts: [{ text: system }] },
86
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
87
+ generationConfig: { maxOutputTokens: MAX_TOKENS },
88
+ }),
89
+ });
90
+ const text = await res.text();
91
+ if (!res.ok) throw new Error(`Gemini ${res.status}: ${text.slice(0, 200)}`);
92
+ const parts = JSON.parse(text).candidates?.[0]?.content?.parts || [];
93
+ return parts.map((p) => p.text || "").join("").trim();
94
+ }
95
+ async function callOllama(r, system, prompt) {
96
+ const res = await fetch(`${r.baseUrl}/api/chat`, {
97
+ method: "POST", headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
99
+ });
100
+ const text = await res.text();
101
+ if (!res.ok) {
102
+ if (/not found|no such model|try pulling/i.test(text)) throw new Error(`the model "${r.model}" isn't downloaded yet — run symbiot setup-local --model ${r.model} (or click "Set up a free local model" in Settings)`);
103
+ throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
104
+ }
105
+ return (JSON.parse(text).message?.content || "").trim();
106
+ }
107
+
108
+ // What write() shows in a terminal while it works: the CLI sets these (its
109
+ // spinner, its coloured hints); imported on its own, it stays quiet.
110
+ const AI_UI = {
111
+ spinner: () => () => {},
112
+ notConnected: () => console.log("Symbiot needs an AI to write your updates. Connect one with: symbiot login"),
113
+ rejected: (label) => console.log(`Your ${label} credentials were rejected. Reconnect with: symbiot login --force`),
114
+ };
115
+ async function write(system, prompt) {
116
+ const r = resolveProvider();
117
+ if (!r) { AI_UI.notConnected(); return null; }
118
+ const stop = AI_UI.spinner("thinking…");
119
+ try {
120
+ if (r.provider === "anthropic") return await callAnthropic(r, system, prompt);
121
+ if (r.provider === "openai") return await callOpenAI(r, system, prompt);
122
+ if (r.provider === "gemini") return await callGemini(r, system, prompt);
123
+ if (r.provider === "ollama") return await callOllama(r, system, prompt);
124
+ return null;
125
+ } catch (err) {
126
+ if (/\b401\b|\b403\b|invalid|authentication|api key|unauthor/i.test(err?.message || "")) {
127
+ AI_UI.rejected(PROVIDERS[r.provider].label);
128
+ return null;
129
+ }
130
+ return `Couldn't reach the model: ${err?.message || err}`;
131
+ } finally { stop(); }
132
+ }
133
+
134
+ async function validate(provider, { key, baseUrl } = {}) {
135
+ try {
136
+ if (provider === "anthropic") { await new Anthropic({ apiKey: key }).models.list(); return true; }
137
+ if (provider === "openai") return (await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${key}` } })).ok;
138
+ if (provider === "gemini") return (await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`)).ok;
139
+ if (provider === "ollama") return (await fetch(`${baseUrl}/api/tags`)).ok;
140
+ } catch { return false; }
141
+ return false;
142
+ }
143
+
144
+ // ---- hardware -> model recommendations ------------------------------------
145
+ function detectGpu() {
146
+ const nv = sh("nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null").trim();
147
+ if (nv) { const p = nv.split("\n")[0].split(","); return { name: (p[0] || "NVIDIA GPU").trim(), vramGB: Math.round(Number(p[1]) / 1024) || null, kind: "nvidia" }; }
148
+ if (process.platform === "linux") { const vga = sh("lspci 2>/dev/null | grep -iE 'vga|3d controller|display' | head -1").replace(/^.*?: /, "").trim(); if (vga) return { name: vga, vramGB: null, kind: "other" }; }
149
+ if (process.platform === "darwin") { const chip = sh("sysctl -n machdep.cpu.brand_string 2>/dev/null").trim(); if (/apple/i.test(chip)) return { name: chip + " (unified memory)", vramGB: null, kind: "apple" }; }
150
+ return null;
151
+ }
152
+ function detectHardware() {
153
+ const cpus = oscpus() || [];
154
+ return { platform: process.platform, arch: process.arch, ramGB: Math.round(totalmem() / 1073741824), cpuCount: cpus.length, cpuModel: ((cpus[0] && cpus[0].model) || "CPU").trim(), gpu: detectGpu() };
155
+ }
156
+ function recommendModels(hw) {
157
+ const ram = hw.ramGB || 8;
158
+ const local = [
159
+ { tier: "min", model: "llama3.2:3b", needGB: 6, note: "fast & light — fine for summaries" },
160
+ { tier: "med", model: "llama3.1:8b", needGB: 10, note: "solid all-rounder" },
161
+ { tier: "max", model: "qwen2.5:14b", needGB: 18, note: "stronger reasoning" },
162
+ ];
163
+ if (ram >= 40) local.push({ tier: "max+", model: "llama3.1:70b", needGB: 48, note: "top local quality — big machine/GPU" });
164
+ local.forEach((m) => { m.fits = ram >= m.needGB; });
165
+ const paid = [
166
+ { provider: "anthropic", tier: "cheap", model: "claude-haiku-4-5", note: "cheapest Claude" },
167
+ { provider: "anthropic", tier: "top", model: "claude-opus-5-5", note: "best Claude (default)" },
168
+ { provider: "openai", tier: "cheap", model: "gpt-4o-mini", note: "cheap OpenAI" },
169
+ { provider: "openai", tier: "top", model: "gpt-4o", note: "stronger OpenAI" },
170
+ { provider: "gemini", tier: "cheap", model: "gemini-1.5-flash", note: "cheap Google" },
171
+ { provider: "gemini", tier: "top", model: "gemini-1.5-pro", note: "stronger Google" },
172
+ ];
173
+ const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
174
+ return { local, paid, best };
175
+ }
176
+ // ---- local model one-command setup (Ollama), OS-aware ---------------------
177
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
178
+ function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
179
+ function ollamaInstall() {
180
+ if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
181
+ if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
182
+ return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
183
+ }
184
+ async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
185
+ async function ensureOllama() {
186
+ if (await ollamaUp()) return true;
187
+ try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
188
+ for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
189
+ return false;
190
+ }
191
+ function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
192
+
193
+ // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
194
+ async function connectProvider(b) {
195
+ const provider = b && b.provider;
196
+ if (!PROVIDERS[provider]) return { ok: false, message: "Unknown provider." };
197
+ const cfg = loadConfig();
198
+ if (provider === "ollama") {
199
+ const baseUrl = (b.baseUrl || "http://localhost:11434").trim();
200
+ const model = (b.model || "").trim() || PROVIDERS.ollama.model;
201
+ if (!(await validate("ollama", { baseUrl }))) return { ok: false, message: `Couldn't reach Ollama at ${baseUrl}. Is it running?` };
202
+ cfg.provider = "ollama"; cfg.ollama = { baseUrl, model }; delete cfg.apiKey;
203
+ return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS.ollama.label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
204
+ }
205
+ const key = (b.key || "").trim();
206
+ if (!key) return { ok: false, message: "No key entered." };
207
+ const model = (b.model || "").trim() || PROVIDERS[provider].model;
208
+ if (!(await validate(provider, { key }))) return { ok: false, message: `That key didn't work for ${PROVIDERS[provider].label}.` };
209
+ cfg.provider = provider; cfg[provider] = { apiKey: key, model }; delete cfg.apiKey;
210
+ return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
211
+ }
212
+
213
+ export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel };
package/core.mjs CHANGED
@@ -1,9 +1,22 @@
1
- // Shared basics for every module: Symbiot's config and task files, the one
2
- // shell helper (never throws, never hangs) and a repo's working-tree state.
1
+ // Shared basics for every module: Symbiot's version (and npm's newest), its
2
+ // config and task files, the one shell helper (never throws, never hangs) and a
3
+ // repo's working-tree state.
3
4
  import { execSync } from "node:child_process";
4
5
  import { homedir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ // ---- version ----------------------------------------------------------------
11
+ const HERE = fileURLToPath(new URL(".", import.meta.url));
12
+ let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
13
+ let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
14
+ // a.b.c numeric compare: only a HIGHER npm version is an update (a local build
15
+ // ahead of npm must not be offered a "newer" older one)
16
+ function semverGt(a, b) { const p = (v) => String(v || "").replace(/^v/, "").split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0); const x = p(a), y = p(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }
17
+ // SYMBIOT_REGISTRY points the check at another registry (the tests use a fake one)
18
+ const REGISTRY = (process.env.SYMBIOT_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
19
+ async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
7
20
 
8
21
  // ---- config ---------------------------------------------------------------
9
22
  const CONFIG_DIR = join(homedir(), ".config", "symbiot");
@@ -71,4 +84,4 @@ function repoState(repoPath) {
71
84
  return { branch, dirty, del, mod, add, stale, staleBy, behind };
72
85
  }
73
86
 
74
- export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState };
87
+ export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState };
package/desktop.mjs CHANGED
@@ -84,8 +84,12 @@ function notifyCmd(title, body, platform = OS) {
84
84
  const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
85
85
  return ["powershell", ["-NoProfile", "-WindowStyle", "Hidden", "-Command", `Add-Type -AssemblyName System.Windows.Forms; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(10000, ${q(title)}, ${q(body)}, 'Info'); Start-Sleep -Seconds 11; $n.Dispose()`]];
86
86
  }
87
- // Android (Termux): termux-notification, from the termux-api package + the Termux:API app
88
- if (platform === "android") return hasCmd("termux-notification") ? ["termux-notification", ["--id", "symbiot", "--title", title, "--content", body]] : null;
87
+ // Android (Termux): termux-notification, from the termux-api package + the Termux:API app.
88
+ // One per source: Watch's "3 new · Inbox" replaces Inbox's last one, not GitHub's.
89
+ if (platform === "android") {
90
+ const from = (String(title).match(/ · (.+)$/) || [])[1], id = from ? "symbiot-" + from.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) : "symbiot";
91
+ return hasCmd("termux-notification") ? ["termux-notification", ["--id", id, "--title", title, "--content", body]] : null;
92
+ }
89
93
  return hasCmd("notify-send") ? ["notify-send", ["--app-name=Symbiot", title, body]] : null;
90
94
  }
91
95
  // In the app: a line in a file its service watches, and it posts the notification.
package/drift.mjs CHANGED
@@ -1,8 +1,9 @@
1
- // Drift facts for one repo: what's out of sync, stuck or at risk, from git
2
- // alone (plus gh for --ci and the user's own deploy commands).
1
+ // Drift: what's out of sync, stuck or at risk in each repo, from git alone
2
+ // (plus gh for --ci and the user's own deploy commands), and across all of them.
3
3
  import { join } from "node:path";
4
4
  import { readFileSync, statSync } from "node:fs";
5
5
  import { CONFIG_DIR, sh, hasCmd, repoState } from "./core.mjs";
6
+ import { SCAN, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, scanBase, findAllRepos } from "./scan.mjs";
6
7
 
7
8
  // ---- drift: what's out of sync / stuck / at risk (deterministic git facts) -
8
9
  function gitDefaultBranch(repo) {
@@ -121,4 +122,19 @@ function driftRepo(p, opts = {}) {
121
122
  return { name, path: p, def, flags, fetchAgeDays: fa };
122
123
  }
123
124
 
124
- export { gitDefaultBranch, loadDeploys, driftRepo };
125
+ // ---- drift across every repo (the same repos the Map finds) ----------------
126
+ function computeDrift(opts = {}) {
127
+ const own = scanBegin();
128
+ try {
129
+ const deploys = loadDeploys();
130
+ const found = findAllRepos().slice(0, 20), repos = [];
131
+ scanPhase("checking drift", found.length);
132
+ for (const r of found) {
133
+ if (scanExpired()) break;
134
+ repos.push(driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch })); scanTick(r.name);
135
+ }
136
+ return { repos, ci: !!opts.ci, partial: SCAN.partial, base: scanBase() };
137
+ } finally { scanEnd(own); }
138
+ }
139
+
140
+ export { gitDefaultBranch, loadDeploys, driftRepo, computeDrift };