symbiot 0.50.2 → 0.51.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 +17 -0
- package/README.md +133 -2
- package/adapt.mjs +1 -1
- package/agents.mjs +65 -16
- package/core.mjs +26 -2
- package/handback.mjs +178 -0
- package/handover.mjs +33 -3
- package/headless.mjs +29 -4
- package/home.mjs +15 -4
- package/index.mjs +192 -3
- package/knowledge.mjs +361 -0
- package/lanes.mjs +9 -8
- package/links.mjs +4 -1
- package/mail.mjs +27 -1
- package/mind.mjs +14 -9
- package/package.json +7 -2
- package/post.mjs +317 -0
- package/reports.mjs +136 -0
- package/sandbox.mjs +55 -0
- package/server.mjs +37 -2
- package/tasks.mjs +54 -20
- package/ui.mjs +163 -23
- package/watch.mjs +113 -20
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.51.0
|
|
6
|
+
|
|
7
|
+
What your agents built on the symbiot repo yesterday, merged with everything since:
|
|
8
|
+
|
|
9
|
+
- **`symbiot post`.** Drafts three posts a week from your real git activity, releases and CHANGELOG (shipped, learned or fixed, and a longer one) in your voice from `voice.md`. They wait on the Dashboard for Approve, Edit or Skip; nothing is published without your approval, and every choice is logged. It can draft replies to comments too, for you to approve.
|
|
10
|
+
- **Company knowledge folders.** Point Symbiot at a folder of documents (Settings → Knowledge folders) and every chat can quote them and say which file; worked examples are kept apart from facts.
|
|
11
|
+
- **Reports.** What agents write up (findings, audits, plans) shows in one place, unread ones marked.
|
|
12
|
+
- **Waiting on replies.** When an agent's email needs an answer, Symbiot watches your inbox for it and hands the reply on to the next step itself.
|
|
13
|
+
- **Facts from a run.** An agent can hand back what it learned; Remember or Skip it on the run's block.
|
|
14
|
+
- **A first-run sandbox**, to walk through Symbiot's setup like a new user, apart from your real data.
|
|
15
|
+
- **Fixed:** the same task listed twice in different words, or spelled out in steps; a task you closed coming back; long tasks cut mid-sentence; mail and WhatsApp chats you've already read still counting as waiting on you; agent runs denied the Gmail, Calendar, Drive and Notion tools right after you connected them.
|
|
16
|
+
|
|
17
|
+
## 0.50.3
|
|
18
|
+
|
|
19
|
+
- **Finer tags on the spheres.** A sphere's tag is the short form of its task: no "(§5 of .symbiot/BRIEF…)" asides, paths or markdown, cut at a word, in smaller type and two lines at most; point at it for the whole text. The layout makes room for every tag, so a tag never lands on another tag or sphere.
|
|
20
|
+
- **Go sits beside Home** at the top of the work view, clear of the chat.
|
|
21
|
+
|
|
5
22
|
## 0.50.2
|
|
6
23
|
|
|
7
24
|
- **An allow list is one click.** When an agent needs permissions it doesn't have, it proposes them, as narrow as the task needs, in `.symbiot/allowlist.proposed.json` and offers it as a choice. Pick it in the app and Symbiot turns it on for that folder only and runs the agent again: no copying files, no running claude in a terminal. A list that would let an agent run anything at all (a shell, sudo, rm) is never turned on by a click.
|
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@ The newer extras are [experimental](#experimental): email, CI checks,
|
|
|
24
24
|
local-model setup, Screens and Watch, and Symbiot on a phone. The list there says
|
|
25
25
|
what's stable and what isn't.
|
|
26
26
|
|
|
27
|
+
## What is Symbiot for?
|
|
28
|
+
|
|
29
|
+
For developers and small teams who owe someone an update every week (a manager,
|
|
30
|
+
a client, a standup, the team channel) and would rather spend that time on the
|
|
31
|
+
work. The work is already in git: what you committed, merged and released, and
|
|
32
|
+
what's still uncommitted or out of sync. Symbiot reads it on your machine and
|
|
33
|
+
writes the update from it, so it says what actually happened, not what you
|
|
34
|
+
remember on a Friday afternoon. It also hands the next tasks to your coding
|
|
35
|
+
agent and brings its work back to you to approve.
|
|
36
|
+
|
|
27
37
|
## Install
|
|
28
38
|
|
|
29
39
|
```bash
|
|
@@ -103,6 +113,8 @@ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of
|
|
|
103
113
|
did** — files changed and commits made, read from git, whichever agent it was.
|
|
104
114
|
When an agent leaves **questions, options or ideas** for you, they show up on its
|
|
105
115
|
block (see [Questions from your agent](#questions-from-your-agent)).
|
|
116
|
+
- **Reports** — what your agents wrote up for you (findings, audits, plans), to
|
|
117
|
+
read in the app (see [What a run hands back](#what-a-run-hands-back-facts-to-remember-replies-to-wait-for-reports)).
|
|
106
118
|
- **Settings** — [Link your work](#link-your-work), your AI, the folders to scan, your agent command, the
|
|
107
119
|
[weekly write-up and start at login](#every-week-and-at-login), plus the
|
|
108
120
|
[experimental](#experimental) email, model recommendations and one-click local
|
|
@@ -127,6 +139,15 @@ the chrome-less window, it uses your default browser. On an Android phone it run
|
|
|
127
139
|
in Termux ([experimental](#on-your-phone-android-in-termux)), or as an
|
|
128
140
|
[Android app](#the-android-app-apk) of its own.
|
|
129
141
|
|
|
142
|
+
**Try it as someone new: `symbiot app --fresh`.** A brand-new Symbiot opens next
|
|
143
|
+
to yours, marked **sandbox** by its version, to walk through first run and
|
|
144
|
+
setup: no AI connected (keys in your environment are left out), no memory, no
|
|
145
|
+
linked accounts, no lanes, none of your repos. It has its own home folder under
|
|
146
|
+
your temp folder, deleted when you quit it (`--keep` keeps it, to see what setup
|
|
147
|
+
wrote), and it never offers an update. A folder you add to it is read like any
|
|
148
|
+
other, and agents it starts run as you on this computer, without your Claude
|
|
149
|
+
sign-in.
|
|
150
|
+
|
|
130
151
|
### Every week, and at login
|
|
131
152
|
|
|
132
153
|
Two switches in Settings do what the old `symbiot-desktop` tray app did (that
|
|
@@ -377,6 +398,13 @@ Handoff says which of your linked sites aren't wired up, and each run's `TASKS.m
|
|
|
377
398
|
says which connectors it has and which it doesn't, so it doesn't claim to have
|
|
378
399
|
checked your mail.
|
|
379
400
|
|
|
401
|
+
Claude writes a claude.ai connector into `~/.claude.json` only after a session
|
|
402
|
+
has connected it, so the first run after you connect Gmail used to get Gmail's
|
|
403
|
+
tools with every call denied. A site you link in Symbiot (Gmail, Google Calendar,
|
|
404
|
+
Google Drive, Notion) now gets its claude.ai connector's rule on every Claude run,
|
|
405
|
+
even before Claude records it or while it waits to be authorized. A rule for tools
|
|
406
|
+
a run doesn't have does nothing.
|
|
407
|
+
|
|
380
408
|
Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
|
|
381
409
|
**Agents** tab. The command is saved as `agentCmd` in
|
|
382
410
|
`~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
|
|
@@ -548,7 +576,7 @@ Symbiot's config folder, readable by you only.
|
|
|
548
576
|
**Connections** (on the Dashboard, and in Settings) has one entry per standard work site: Gmail,
|
|
549
577
|
Outlook, Google and Outlook Calendar, GitHub, GitLab, Slack, Microsoft Teams,
|
|
550
578
|
WhatsApp, Jira & Confluence, Linear, Asana, Trello, Google Drive, Notion,
|
|
551
|
-
HubSpot and
|
|
579
|
+
HubSpot, Salesforce and LinkedIn. One click:
|
|
552
580
|
|
|
553
581
|
1. opens the site in Symbiot's own browser, where you sign in the usual way (its
|
|
554
582
|
own login, SSO and 2FA; Symbiot never sees your password). Close the window
|
|
@@ -578,6 +606,43 @@ hides the ones you don't use. An entry with a built-in's id replaces it:
|
|
|
578
606
|
Linking stays in the app. Like trusted sites, there's no command an agent could
|
|
579
607
|
call to link a site itself.
|
|
580
608
|
|
|
609
|
+
## Post about your week: `symbiot post`
|
|
610
|
+
|
|
611
|
+
`symbiot post` turns your last 7 days of real work into 3 draft LinkedIn posts,
|
|
612
|
+
in your own voice: what you **shipped**, one thing you **learned or fixed**, and a
|
|
613
|
+
**longer post** (or a short thread) about the best of it. It reads the commits,
|
|
614
|
+
release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scans,
|
|
615
|
+
and nothing else. **It never posts by itself**, and it doesn't schedule.
|
|
616
|
+
|
|
617
|
+
- **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
|
|
618
|
+
line of `---` between each. Or link LinkedIn (above) and click **Fill from
|
|
619
|
+
LinkedIn** on the Dashboard (`symbiot post voice --linkedin`), which reads your
|
|
620
|
+
recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
|
|
621
|
+
- **Only what git shows.** Your AI gets the week as numbered facts and must say
|
|
622
|
+
which ones each post uses. A post that names a number, version, name or user
|
|
623
|
+
count the facts don't show is sent back once, then dropped.
|
|
624
|
+
- **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
|
|
625
|
+
**Edit** and **Skip**. Approve copies the post to your clipboard and opens
|
|
626
|
+
LinkedIn's share box: you paste it and post it yourself.
|
|
627
|
+
- **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
|
|
628
|
+
edit, approval and skip, with its text, date and platform.
|
|
629
|
+
- **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
|
|
630
|
+
show on its Dashboard card, marked **maybe a customer** when they ask how to
|
|
631
|
+
install it, what it costs or about team use. **Draft a reply** has your agent
|
|
632
|
+
type a reply into the comment box without posting it (it can't press Post,
|
|
633
|
+
Comment, Reply or Send), and write it in its brief for you to paste.
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
symbiot post # draft this week's 3 posts
|
|
637
|
+
symbiot post list # the drafts waiting on you
|
|
638
|
+
symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
|
|
639
|
+
symbiot post edit <id> "text" # change a draft's words
|
|
640
|
+
symbiot post skip <id> # drop one
|
|
641
|
+
symbiot post log # everything that happened
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
With no AI connected it says so and does nothing else.
|
|
645
|
+
|
|
581
646
|
## One Symbiot, everywhere
|
|
582
647
|
|
|
583
648
|
The chat on each Dashboard card and the chat on each task aren't separate bots:
|
|
@@ -624,6 +689,33 @@ hand, a cable, which network you're on), your identity or secrets (signing in, a
|
|
|
624
689
|
account, spending money, sending something in your name). Every brief tells the
|
|
625
690
|
agent so, and such a question says which: `👤 You (only you: your Meta token): …`.
|
|
626
691
|
|
|
692
|
+
### What a run hands back: facts to remember, replies to wait for, reports
|
|
693
|
+
|
|
694
|
+
Every brief also tells the agent what else it can leave in `.symbiot/`:
|
|
695
|
+
|
|
696
|
+
- **`REMEMBER.json`**: lasting facts it found (who someone is, which account is
|
|
697
|
+
what, a decision), as `[{ "name", "kind", "fact" }]`. A run can't write
|
|
698
|
+
Symbiot's memory itself. Once it stops, its block in the Agents tab shows them,
|
|
699
|
+
each ticked, with **Remember** and **Skip**. Remember puts the ticked ones into
|
|
700
|
+
memory, marked with the run's name; Skip drops them. Either way it's asked once.
|
|
701
|
+
- **`AWAITING.json`**: an email it sent (or drafted for you to send) that waits on
|
|
702
|
+
a reply, as `[{ "to", "subject", "asked", "next", "task", "lane" }]`. Symbiot
|
|
703
|
+
watches for the reply itself, so you never have to say "they replied": in what
|
|
704
|
+
Watch finds new in your inbox, and, with Email switched on, in the inbox beside
|
|
705
|
+
the Sent folder you linked (headers only, and only the reply it waits for). When
|
|
706
|
+
it's in, the lane that does the next step (`lane`, else the one that sent it)
|
|
707
|
+
gets it as a task and its agent starts, told what was asked for and what to do
|
|
708
|
+
next. The task it's for says so in its chat, and you get a notification. The
|
|
709
|
+
Dashboard lists **Waiting on replies**, with **Stop waiting** on each. Anything
|
|
710
|
+
the reply leaves for you to decide comes back as that agent's question.
|
|
711
|
+
- **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
|
|
712
|
+
is, with a `# ` title. **Reports** in the app lists every one your runs left,
|
|
713
|
+
in your repos and in runs of their own, newest first, unread ones marked; click
|
|
714
|
+
one to read it there, tables and all. Home shows how many are unread.
|
|
715
|
+
`symbiot reports` lists them in a terminal, and `symbiot reports <id>` prints
|
|
716
|
+
one. Symbiot's own files there (TASKS, QUESTIONS, ANSWERS, HANDOFF, SKIPPED)
|
|
717
|
+
and the briefs handed in (`BRIEF*.md`) aren't reports.
|
|
718
|
+
|
|
627
719
|
## Connect it (once)
|
|
628
720
|
|
|
629
721
|
Pick the AI you want it to write with:
|
|
@@ -688,12 +780,44 @@ coloured) with their languages, and can receive tasks like a repo.
|
|
|
688
780
|
gigantic folder — it stops and shows what it found so far, marked **partial**,
|
|
689
781
|
instead of hanging. The CLI shows scan progress on one line as it goes.
|
|
690
782
|
|
|
783
|
+
### Knowledge folders
|
|
784
|
+
|
|
785
|
+
Repos are your code; a **knowledge folder** is your documents — a company folder
|
|
786
|
+
of how work is done, policies, price lists, notes. Add one under **Settings →
|
|
787
|
+
Knowledge folders** in the app, or with `symbiot knowledge add ~/Company`. Symbiot
|
|
788
|
+
reads its Markdown, CSV and text files (Word, PDF and Excel are counted as "not
|
|
789
|
+
read yet") into an index of its own, `~/.config/symbiot/knowledge.json`, readable
|
|
790
|
+
only by you and kept apart from its memory. While the app runs it re-reads only
|
|
791
|
+
the files that changed, every few minutes; `symbiot knowledge index` does it now.
|
|
792
|
+
|
|
793
|
+
Every chat in the app — the home bar, a Dashboard card, a task — then gets the few
|
|
794
|
+
passages that best answer what you asked, each with the file it came from, and
|
|
795
|
+
quotes and cites that file when it uses one.
|
|
796
|
+
|
|
797
|
+
- **Examples aren't facts.** Say which paths in a folder hold worked examples
|
|
798
|
+
(`--examples templates/,active/`, or the second box in Settings; `templates/`
|
|
799
|
+
unless you say otherwise, meaning any folder of that name). A file whose front
|
|
800
|
+
matter says `example: true` is one too. Examples never come back in a chat;
|
|
801
|
+
`symbiot knowledge search … --with-examples` finds them, marked as examples.
|
|
802
|
+
Add the folder again to change its examples.
|
|
803
|
+
- **Cases.** A folder's `README.md` with *Who does it*, *When*, *Steps*, *Hands
|
|
804
|
+
off to* and *What Symbiot should learn and remember*, and items whose front
|
|
805
|
+
matter has `owner`, `status`, `due` and `waiting_on` (like
|
|
806
|
+
`active/<item>/STATUS.md`), answer "what's waiting on me?" and "who owns X?"
|
|
807
|
+
straight from those fields. "Me" is the name in `symbiot knowledge me "Your
|
|
808
|
+
Name"` (saved as `myName` in config.json), or else your git `user.name`; your
|
|
809
|
+
first name on its own counts too.
|
|
810
|
+
- `symbiot knowledge` lists the folders and what was read; `search`, `waiting`
|
|
811
|
+
and `owner` answer from the terminal (`symbiot knowledge help`).
|
|
812
|
+
|
|
691
813
|
## What it reads, and what it doesn't
|
|
692
814
|
|
|
693
815
|
- **Reads:** your local git — commit messages and changed-file names, plus
|
|
694
816
|
`TODO`/`FIXME` markers and uncommitted changes for `todo`. If you switch
|
|
695
817
|
[email](#your-sent-email-without-an-api-symbiot-mail) on, it also reads the headers
|
|
696
|
-
of mail you sent (subject, recipients, date)
|
|
818
|
+
of mail you sent (subject, recipients, date), and, while an agent's email
|
|
819
|
+
[waits on a reply](#what-a-run-hands-back-facts-to-remember-replies-to-wait-for-reports),
|
|
820
|
+
the headers in your inbox there to find that reply (only it is kept). All local.
|
|
697
821
|
- **Sends to the AI:** commit messages and dates, changed-file **names**, the
|
|
698
822
|
folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
|
|
699
823
|
the README and any `CLAUDE.md`/`AGENTS.md` conventions. With email on, it also
|
|
@@ -934,6 +1058,13 @@ chats with unread messages from them count on the Dashboard and in Standup, get
|
|
|
934
1058
|
notification and a brief, and the brief and the card's chat are told who each one
|
|
935
1059
|
is from.
|
|
936
1060
|
|
|
1061
|
+
**Mail: only unread mail needs you.** Gmail starts each unread row with "unread".
|
|
1062
|
+
Symbiot keeps that mark, so an email you've already read (on your phone, say) is
|
|
1063
|
+
listed on the card marked "read", but it isn't counted, notified or briefed. One
|
|
1064
|
+
you read after Watch found it stops counting at the next read, and counts again if
|
|
1065
|
+
you mark it unread. Another inbox gets the same once Watch has seen it mark a row
|
|
1066
|
+
unread. Until then, every new email counts.
|
|
1067
|
+
|
|
937
1068
|
**Draft a reply.** A new email under Watching has **Draft a reply**. It hands the
|
|
938
1069
|
email to your coding agent (the command in Settings → Handoff), which opens it in
|
|
939
1070
|
your inbox through Screens, reads it, writes a reply and leaves it in Drafts, for
|
package/adapt.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const MIN_EVENTS = 12; // under this many uses, adaptation fad
|
|
|
48
48
|
const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
|
|
49
49
|
const HYSTERESIS = 0.15;
|
|
50
50
|
const FITTS = { a: 0.1, b: 0.15 }; // seconds; only ratios matter here
|
|
51
|
-
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings"];
|
|
51
|
+
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports"];
|
|
52
52
|
|
|
53
53
|
function loadAdapt(file = ADAPT_FILE) {
|
|
54
54
|
try { const d = JSON.parse(readFileSync(file, "utf8")); return { shapes: d.shapes || {}, trans: d.trans || {}, hours: d.hours || {}, modes: d.modes || {}, slots: d.slots || {}, shown: d.shown || null, events: d.events || 0 }; }
|
package/agents.mjs
CHANGED
|
@@ -7,8 +7,9 @@ import { join, dirname } from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
9
9
|
import { randomBytes, createHash } from "node:crypto";
|
|
10
|
-
import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
10
|
+
import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
11
11
|
import { parseRun, lastRunText } from "./work.mjs";
|
|
12
|
+
import { parseFacts } from "./handover.mjs";
|
|
12
13
|
|
|
13
14
|
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
14
15
|
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
@@ -84,14 +85,20 @@ function claudeConnectors(dir = "", file = join(homedir(), ".claude.json")) {
|
|
|
84
85
|
const proj = dir && j.projects && typeof j.projects === "object" ? j.projects[dir] : null;
|
|
85
86
|
const needsAuth = new Set(names(j.mcpNeedsAuthNoticed));
|
|
86
87
|
return [...new Set([...names(j.claudeAiMcpEverConnected), ...servers(j.mcpServers), ...servers(proj && proj.mcpServers)])]
|
|
87
|
-
.map((name) => ({ name, rule:
|
|
88
|
+
.map((name) => ({ name, rule: connectorRule(name), ready: !needsAuth.has(name) }));
|
|
88
89
|
}
|
|
89
90
|
// A Claude command with the ready connectors' rules added, for this run only:
|
|
90
91
|
// the saved command stays as typed, so linking or unlinking one takes effect on
|
|
91
92
|
// the next run. Any other agent's command runs as-is (Settings says so).
|
|
92
|
-
|
|
93
|
+
// Claude records a claude.ai connector (claudeAiMcpEverConnected) only once a
|
|
94
|
+
// session has connected it, so the first run after you connect Gmail got Gmail's
|
|
95
|
+
// tools without a rule for them, and every call was denied. A site linked in
|
|
96
|
+
// Symbiot is allowed its claude.ai connector's tools too, recorded or not (a rule
|
|
97
|
+
// for tools a run doesn't have does nothing).
|
|
98
|
+
function withConnectors(tmpl, dir, file, linked = loadConfig().linked) {
|
|
93
99
|
if (!isClaudeCmd(tmpl)) return tmpl;
|
|
94
|
-
|
|
100
|
+
const rules = [...claudeConnectors(dir, file).filter((c) => c.ready).map((c) => c.rule), ...linkedRules(linked)];
|
|
101
|
+
return rules.reduce((cmd, r) => allowTool(cmd, r), tmpl).replace(/\s+$/, "");
|
|
95
102
|
}
|
|
96
103
|
// Linking a site in Symbiot (Links: Gmail, Drive…) signs Symbiot's own browser in;
|
|
97
104
|
// it doesn't give Claude's runs that site's tools. Those come from Claude's own
|
|
@@ -101,6 +108,14 @@ function withConnectors(tmpl, dir, file) {
|
|
|
101
108
|
// with the one it has (connector: its name, or "") and whether it's ready.
|
|
102
109
|
const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
|
|
103
110
|
const LINK_CONNECTOR = { gmail: ["Gmail", /gmail/i], outlook: ["Outlook", /outlook|microsoft 365/i], gcal: ["Google Calendar", /google calendar/i], gdrive: ["Google Drive", /google drive/i], notion: ["Notion", /notion/i], slack: ["Slack", /slack/i], jira: ["Jira & Confluence", /atlassian|jira|confluence/i], linear: ["Linear", /linear/i], asana: ["Asana", /asana/i], hubspot: ["HubSpot", /hubspot/i] };
|
|
111
|
+
// The claude.ai connector each of those is, by the name Claude gives it (as seen
|
|
112
|
+
// in ~/.claude.json), for its rule before Claude has recorded it (withConnectors).
|
|
113
|
+
const CLAUDE_AI_CONNECTOR = { gmail: "claude.ai Gmail", gcal: "claude.ai Google Calendar", gdrive: "claude.ai Google Drive", notion: "claude.ai Notion" };
|
|
114
|
+
const connectorRule = (name) => "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
115
|
+
function linkedRules(l) {
|
|
116
|
+
if (!l || typeof l !== "object" || Array.isArray(l)) return [];
|
|
117
|
+
return Object.keys(l).filter((id) => CLAUDE_AI_CONNECTOR[id]).map((id) => connectorRule(CLAUDE_AI_CONNECTOR[id]));
|
|
118
|
+
}
|
|
104
119
|
function linkedConnectors(conns = claudeConnectors(), l = loadConfig().linked) {
|
|
105
120
|
if (!l || typeof l !== "object" || Array.isArray(l)) return [];
|
|
106
121
|
return Object.keys(l).filter((id) => LINK_CONNECTOR[id]).map((id) => {
|
|
@@ -116,7 +131,10 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
|
|
|
116
131
|
const nm = (s) => s.replace(/^claude\.ai\s+/i, ""), names = off.map((x) => x.name), them = names.length > 1 ? names.slice(0, -1).join(", ") + " and " + names.at(-1) : names[0];
|
|
117
132
|
const has = ready.length ? `this run can use ${ready.map((c) => `${nm(c.name)} (\`${c.rule}__…\` tools)`).join(", ")}.` : "";
|
|
118
133
|
const not = off.length ? ` ${them} ${off.length === 1 ? "is" : "are"} linked in Symbiot but not ${off.some((x) => x.connector) ? "ready " : ""}as a Claude connector, so this run has no tools for ${off.length === 1 ? "it" : "them"}: don't say you checked ${off.length === 1 ? "it" : "them"}. What's new there is in \`node "${CLI}" watch new\`; to read more, ask the user (👤) to connect ${them} in claude.ai → Settings → Connectors.` : "";
|
|
119
|
-
|
|
134
|
+
// withConnectors allows these anyway: Claude may have connected one since
|
|
135
|
+
const early = off.filter((x) => CLAUDE_AI_CONNECTOR[x.id]).map((x) => `\`${connectorRule(CLAUDE_AI_CONNECTOR[x.id])}__…\``);
|
|
136
|
+
const anyway = early.length ? ` If ${early.join(" or ")} tools are here after all, the connector was just connected: they're allowed, so use them.` : "";
|
|
137
|
+
return (has + not + anyway).trim();
|
|
120
138
|
}
|
|
121
139
|
// For Settings → Handoff: the connectors, whether this command's runs get them, and
|
|
122
140
|
// the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
|
|
@@ -179,11 +197,11 @@ function earlierRuns() {
|
|
|
179
197
|
const tracked = new Set(HANDOFFS.map((e) => e.path)), out = [];
|
|
180
198
|
for (const r of loadRuns()) {
|
|
181
199
|
if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
|
|
182
|
-
const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path);
|
|
183
|
-
if (!busy && !ask.questions.length && !wait) continue;
|
|
200
|
+
const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
|
|
201
|
+
if (!busy && !ask.questions.length && !wait && !facts.length) continue;
|
|
184
202
|
const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
|
|
185
203
|
let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readFileSync(log, "utf8"))); if (!busy) end = statSync(log).mtimeMs; } catch {}
|
|
186
|
-
out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
|
|
204
|
+
out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, remember: facts.length ? facts : null, fromHeld: false });
|
|
187
205
|
}
|
|
188
206
|
return out;
|
|
189
207
|
}
|
|
@@ -326,18 +344,44 @@ const HELD = "TASKS.next.md";
|
|
|
326
344
|
function writeTasks(path, md) {
|
|
327
345
|
const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
|
|
328
346
|
if (runningHandoff(path)) { writeFileSync(join(dir, HELD), md); return true; }
|
|
329
|
-
writeFileSync(join(dir, "TASKS.md"), md);
|
|
347
|
+
writeFileSync(join(dir, "TASKS.md"), md); noteHanded(path, md);
|
|
330
348
|
try { unlinkSync(join(dir, HELD)); } catch {} // superseded by this brief
|
|
331
349
|
return false;
|
|
332
350
|
}
|
|
351
|
+
// ---- a task the agent deleted from TASKS.md --------------------------------
|
|
352
|
+
// TASKS.md is rebuilt from tasks.json on every send, so an agent deleting a
|
|
353
|
+
// line (the user said to drop that task) changed nothing, and the next brief
|
|
354
|
+
// brought it back (GhostAIChat's `pod install`). handed.json keeps what the
|
|
355
|
+
// brief in TASKS.md handed out; a task it handed out that's no longer in the
|
|
356
|
+
// file (ticked or not) was deleted: droppedTasks lists them, and tasks.mjs
|
|
357
|
+
// closes them (applyDrops). The task lines are the `## Tasks` section's, or the
|
|
358
|
+
// whole file's when it has none; a file emptied or gone deleted nothing.
|
|
359
|
+
const HANDED = "handed.json", BOX = /^\s*-\s*\[[ x]\]\s*/i;
|
|
360
|
+
function briefTasks(md) {
|
|
361
|
+
const lines = String(md || "").split("\n"), at = lines.findIndex((l) => /^##\s+Tasks\s*$/.test(l));
|
|
362
|
+
const end = at < 0 ? lines.length : lines.findIndex((l, i) => i > at && /^##\s/.test(l));
|
|
363
|
+
return lines.slice(at + 1, end < 0 ? lines.length : end).filter((l) => BOX.test(l)).map((l) => l.replace(BOX, "").trim()).filter(Boolean);
|
|
364
|
+
}
|
|
365
|
+
function noteHanded(path, md, also = []) {
|
|
366
|
+
try { writeFileSync(join(path, ".symbiot", HANDED), JSON.stringify({ at: Date.now(), tasks: [...briefTasks(md), ...also] })); } catch {}
|
|
367
|
+
}
|
|
368
|
+
function droppedTasks(path) {
|
|
369
|
+
let h = null; try { h = JSON.parse(readSymbiot(path, HANDED)); } catch {}
|
|
370
|
+
const md = readSymbiot(path, "TASKS.md");
|
|
371
|
+
if (!h || !Array.isArray(h.tasks) || !md.trim() || !/^##\s+Tasks\s*$|^\s*-\s*\[[ x]\]/im.test(md)) return [];
|
|
372
|
+
const now = briefTasks(md);
|
|
373
|
+
return h.tasks.filter((t) => !now.some((n) => sameTask(n, t))).map((text) => ({ text, at: h.at || 0 }));
|
|
374
|
+
}
|
|
333
375
|
// Swap the held brief in, keeping the ticks the agent made meanwhile: a tick
|
|
334
376
|
// is how a task reaches review, so dropping one would lose that task's work.
|
|
377
|
+
// A task the agent deleted meanwhile stays out, and stays deleted in handed.json.
|
|
335
378
|
function releaseHeldTasks(path) {
|
|
336
379
|
const held = readSymbiot(path, HELD); if (!held || runningHandoff(path)) return false;
|
|
337
380
|
const isTick = /^\s*-\s*\[x\]\s*/i, key = (l) => l.replace(/^\s*-\s*\[[ x]\]\s*/i, "").trim().toLowerCase();
|
|
338
381
|
const ticked = new Set(readSymbiot(path, "TASKS.md").split("\n").filter((l) => isTick.test(l)).map(key));
|
|
339
|
-
const
|
|
340
|
-
|
|
382
|
+
const gone = droppedTasks(path).map((d) => d.text), isGone = (l) => /^\s*-\s*\[ \]/.test(l) && gone.some((g) => sameTask(g, key(l)));
|
|
383
|
+
const md = held.split("\n").filter((l) => !isGone(l)).map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
|
|
384
|
+
try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); noteHanded(path, md, gone); return true; } catch { return false; }
|
|
341
385
|
}
|
|
342
386
|
// Land the held brief and, if it leaves anything open, start an agent on it.
|
|
343
387
|
// Runs when an agent this process started exits, and when the app next checks
|
|
@@ -512,7 +556,7 @@ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbio
|
|
|
512
556
|
// bullet under Questions is a question with no options.
|
|
513
557
|
function parseQuestions(md) {
|
|
514
558
|
const questions = [], suggestions = []; let sec = "q", cur = null;
|
|
515
|
-
const clip = (s) => String(s).trim()
|
|
559
|
+
const clip = (s) => clipWords(String(s).trim(), TASK_MAX); // as long as a task holds (addTask), so an idea added in one click arrives whole
|
|
516
560
|
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
517
561
|
const l = raw.trim(); if (!l) continue;
|
|
518
562
|
let m;
|
|
@@ -547,7 +591,7 @@ function suggestionTarget(s) {
|
|
|
547
591
|
const SKIPPED = "SKIPPED.md";
|
|
548
592
|
const skippedIdeas = (path) => [...readSymbiot(path, SKIPPED).matchAll(/^-\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
549
593
|
function skipIdea(path, text) {
|
|
550
|
-
path = String(path || ""); text = String(text || "").replace(/\s+/g, " ").trim()
|
|
594
|
+
path = String(path || ""); text = clipWords(String(text || "").replace(/\s+/g, " ").trim(), TASK_MAX);
|
|
551
595
|
if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
|
|
552
596
|
if (!text) return { error: "No idea to skip." };
|
|
553
597
|
const f = join(path, ".symbiot", SKIPPED);
|
|
@@ -612,7 +656,7 @@ function answerQuestions(path, answers, opts = {}) {
|
|
|
612
656
|
if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
|
|
613
657
|
const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
|
|
614
658
|
const rows = (Array.isArray(answers) ? answers : [])
|
|
615
|
-
.map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0,
|
|
659
|
+
.map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, TASK_MAX) }))
|
|
616
660
|
.filter((x) => x.q && x.a);
|
|
617
661
|
if (!rows.length) return { error: "Pick or type at least one answer." };
|
|
618
662
|
const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
|
|
@@ -642,6 +686,10 @@ function answerQuestions(path, answers, opts = {}) {
|
|
|
642
686
|
if (out.granted) out.note = "Allowed " + out.granted.join(", ") + " for your agents" + (opts.rerun ? ", and carried on. " : ". ") + (out.note || "");
|
|
643
687
|
return out;
|
|
644
688
|
}
|
|
689
|
+
// What a run left for Symbiot's memory (.symbiot/REMEMBER.json, handback.mjs):
|
|
690
|
+
// shown on its block with Remember and Skip once it has stopped. [] when none.
|
|
691
|
+
const FACTS = "REMEMBER.json";
|
|
692
|
+
const factsOf = (path) => parseFacts(readSymbiot(path, FACTS));
|
|
645
693
|
// The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
|
|
646
694
|
function heldTasks(path) {
|
|
647
695
|
const md = readSymbiot(path, HELD);
|
|
@@ -675,8 +723,9 @@ function agentsList() {
|
|
|
675
723
|
let log = ""; try { log = readFileSync(e.log, "utf8"); } catch {}
|
|
676
724
|
const { tail, work, progress } = workOf(e.path, log);
|
|
677
725
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
678
|
-
|
|
726
|
+
const facts = first && e.status !== "running" ? factsOf(e.path) : [];
|
|
727
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
679
728
|
}).concat(earlierRuns());
|
|
680
729
|
}
|
|
681
730
|
|
|
682
|
-
export { withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
|
|
731
|
+
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
|
package/core.mjs
CHANGED
|
@@ -36,6 +36,12 @@ function saveConfig(cfg) {
|
|
|
36
36
|
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
37
37
|
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
38
38
|
function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
|
|
39
|
+
// How much a task holds: one line in TASKS.md, long enough for a handover or a
|
|
40
|
+
// pasted list of steps (1000 cut the Jono email task off mid-sentence). Longer,
|
|
41
|
+
// it keeps its whole text (full) and its line links to that (tasks.mjs addTask).
|
|
42
|
+
const TASK_MAX = 4000;
|
|
43
|
+
// A heading or a one-line summary: cut at a word and marked "…", never mid-word.
|
|
44
|
+
const clipWords = (s, max) => { s = String(s || ""); return s.length > max ? s.slice(0, max - 1).replace(/\s+\S*$/, "") + "…" : s; };
|
|
39
45
|
// Two wordings of one task: the same words once case, spacing and punctuation
|
|
40
46
|
// are set aside (a colon for a bracket, `code` for code), or one is the other
|
|
41
47
|
// with a clause more on the end. An extension only counts when the shorter one
|
|
@@ -48,7 +54,7 @@ function sameTask(a, b) {
|
|
|
48
54
|
const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
|
|
49
55
|
if (x === y) return true;
|
|
50
56
|
const [s, l] = x.length < y.length ? [x, y] : [y, x];
|
|
51
|
-
return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b) || sameAsk(a, b);
|
|
57
|
+
return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b) || sameAsk(a, b) || spelledOut(a, b);
|
|
52
58
|
}
|
|
53
59
|
// Nearly the same task, two ways:
|
|
54
60
|
// - nearly all the same words in the same order, 12+ words each ("…nothing
|
|
@@ -102,6 +108,24 @@ function sameAsk(a, b) {
|
|
|
102
108
|
let both = 0; for (const w of x) if (y.has(w)) both++;
|
|
103
109
|
return both / Math.min(x.size, y.size) >= ASK;
|
|
104
110
|
}
|
|
111
|
+
// A task spelled out in steps is the task it spells out: "A mail connector linked
|
|
112
|
+
// in Symbiot still doesn't reach agent runs (Drive does now). Pass its tools
|
|
113
|
+
// through…" and "In the symbiot repo: a mail connector linked in Symbiot still
|
|
114
|
+
// doesn't reach agent runs, but the Drive connector now does. 1) Find where… 2)…".
|
|
115
|
+
// The steps name much more (CONTRIBUTING.md, agentCmd), so the whole of it is
|
|
116
|
+
// never the same ask; its first sentence, without "In the … repo:", is (sameAsk),
|
|
117
|
+
// set against the other task or that one's first sentence. Stricter than sameAsk
|
|
118
|
+
// on its own, as a first sentence is short: not when each has a word the other
|
|
119
|
+
// hasn't ("a mail connector…" isn't "a calendar connector…").
|
|
120
|
+
const STEPS = /(?:^|\s)1[.)]\s[\s\S]*\s2[.)]\s/;
|
|
121
|
+
const firstSentence = (s) => { const m = String(s || "").match(/^(.*?[.!?])["'`)\]]*(?:\s|$)/s); return m ? m[1] : String(s || ""); };
|
|
122
|
+
function bothAdd(a, b) { const x = askWords(a), y = askWords(b); return [...x].some((w) => !y.has(w)) && [...y].some((w) => !x.has(w)); }
|
|
123
|
+
function spelledOut(a, b) {
|
|
124
|
+
const [s, l] = String(a || "").length < String(b || "").length ? [String(a || ""), String(b || "")] : [String(b || ""), String(a || "")];
|
|
125
|
+
if (!STEPS.test(l) || STEPS.test(s)) return false;
|
|
126
|
+
const head = firstSentence(l.split(/(?:^|\s)1[.)]\s/)[0].trim()).replace(/^in (?:the )?\S+ (?:repo|project|folder)\s*[:,]\s*/i, "");
|
|
127
|
+
return [s, firstSentence(s)].some((t) => sameAsk(head, t) && !bothAdd(head, t));
|
|
128
|
+
}
|
|
105
129
|
// One of each: near-duplicates collapse into the wording that says the most.
|
|
106
130
|
function uniqueTasks(texts) {
|
|
107
131
|
const out = [];
|
|
@@ -156,4 +180,4 @@ function repoState(repoPath) {
|
|
|
156
180
|
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
157
181
|
}
|
|
158
182
|
|
|
159
|
-
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
|
183
|
+
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|