symbiot 0.40.5 → 0.41.1
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 +118 -22
- package/agents.mjs +14 -5
- package/ai.mjs +213 -0
- package/core.mjs +34 -3
- package/desktop.mjs +6 -2
- package/drift.mjs +19 -3
- package/index.mjs +44 -1622
- package/package.json +13 -2
- package/phone.mjs +167 -0
- package/scan.mjs +540 -0
- package/server.mjs +270 -0
- package/tasks.mjs +448 -0
- package/ui.mjs +44 -7
- package/watch.mjs +136 -31
- package/writeups.mjs +251 -0
package/README.md
CHANGED
|
@@ -20,8 +20,9 @@ symbiot logout forget saved credentials
|
|
|
20
20
|
symbiot help
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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.**
|
|
244
|
-
the
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
-
|
|
321
|
-
-
|
|
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.
|
|
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
|
|
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.
|
|
435
|
-
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
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
|
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { loadConfig, saveConfig, loadTasks, sh, hasCmd } from "./core.mjs";
|
|
9
|
+
import { loadConfig, saveConfig, loadTasks, sameTask, sh, hasCmd } from "./core.mjs";
|
|
10
10
|
|
|
11
11
|
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
12
12
|
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
@@ -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
|
-
//
|
|
309
|
-
//
|
|
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, in these words or nearly (sameTask).
|
|
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 &&
|
|
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) && sameTask(t.text, 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
|
|
2
|
-
// shell helper (never throws, never hangs) and a
|
|
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");
|
|
@@ -23,6 +36,24 @@ function saveConfig(cfg) {
|
|
|
23
36
|
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
24
37
|
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
25
38
|
function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
|
|
39
|
+
// Two wordings of one task: the same words once case, spacing and punctuation
|
|
40
|
+
// are set aside (a colon for a bracket, `code` for code), or one is the other
|
|
41
|
+
// with a clause more on the end. An extension only counts when the shorter one
|
|
42
|
+
// is a whole sentence (8+ words), so "Fix the bug" never swallows "Fix the bug
|
|
43
|
+
// in the login form".
|
|
44
|
+
const taskWords = (s) => String(s || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
45
|
+
function sameTask(a, b) {
|
|
46
|
+
const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
|
|
47
|
+
if (x === y) return true;
|
|
48
|
+
const [s, l] = x.length < y.length ? [x, y] : [y, x];
|
|
49
|
+
return s.split(" ").length >= 8 && l.startsWith(s + " ");
|
|
50
|
+
}
|
|
51
|
+
// One of each: near-duplicates collapse into the wording that says the most.
|
|
52
|
+
function uniqueTasks(texts) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const t of texts) { const i = out.findIndex((u) => sameTask(u, t)); if (i < 0) out.push(t); else if (taskWords(t).length > taskWords(out[i]).length) out[i] = t; }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
// ---- shell + git ----------------------------------------------------------
|
|
28
59
|
function sh(cmd) {
|
|
@@ -71,4 +102,4 @@ function repoState(repoPath) {
|
|
|
71
102
|
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState };
|
|
105
|
+
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, 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
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
-
|
|
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 };
|