symbiot 0.41.7 → 0.42.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 +63 -11
- package/agents.mjs +108 -7
- package/headless.mjs +80 -17
- package/index.mjs +9 -5
- package/links.mjs +149 -0
- package/package.json +3 -2
- package/screens.mjs +28 -5
- package/server.mjs +14 -2
- package/tasks.mjs +1 -0
- package/ui.mjs +87 -15
- package/watch.mjs +66 -8
- package/writeups.mjs +19 -7
package/watch.mjs
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
// when it's signed in, not the page: see readGitHub.
|
|
12
12
|
//
|
|
13
13
|
// Stored in ~/.config/symbiot/watch.json, readable by you only:
|
|
14
|
-
// { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, via?, cleared?, seen: [key…] }],
|
|
14
|
+
// { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, via?, cleared?, chat?, seen: [key…] }],
|
|
15
15
|
// news: [{ id, watch, name, ts, text, href? }],
|
|
16
16
|
// briefs: [{ id, watch, name, ts, count, text }] }
|
|
17
17
|
// `last` is when it was last read, `checked` when a read last worked; `seen`
|
|
18
18
|
// holds what's been listed, as itemKey()s; `cleared` is when you clicked Seen on
|
|
19
|
-
// its Dashboard card (what's new counts from then)
|
|
19
|
+
// its Dashboard card (what's new counts from then); `chat` is your talk with your AI
|
|
20
|
+
// about that card ([{ role, text, ts }]: boardChat). A brief is your AI's read of one
|
|
20
21
|
// batch of news (config.watchBrief switches it on), with the same `ts`.
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,7 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
|
24
25
|
import { execFile } from "node:child_process";
|
|
25
26
|
import { randomBytes } from "node:crypto";
|
|
26
27
|
import { VERSION, CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
|
|
27
|
-
import { readPage, siteUrl, isTrusted } from "./headless.mjs";
|
|
28
|
+
import { readPage, siteUrl, isTrusted, signIn } from "./headless.mjs";
|
|
28
29
|
import { loadScreens } from "./screens.mjs";
|
|
29
30
|
import { desktopNotify } from "./desktop.mjs";
|
|
30
31
|
import { resolveProvider, write } from "./ai.mjs";
|
|
@@ -32,7 +33,9 @@ import { handoffCmd, runHandoff, runningHandoff } from "./agents.mjs";
|
|
|
32
33
|
|
|
33
34
|
const WATCH_FILE = join(CONFIG_DIR, "watch.json");
|
|
34
35
|
const EVERY = [5, 15, 30, 60]; // minutes between reads
|
|
35
|
-
|
|
36
|
+
// 40 watches: every standard Link (links.mjs) plus a company's own and yours; 1000 news: a
|
|
37
|
+
// busy inbox can't push the rest of the week out before Week is written
|
|
38
|
+
const MAX_WATCHES = 40, MAX_SEEN = 1000, MAX_NEWS = 1000, MAX_PER_READ = 25, MAX_BRIEFS = 50;
|
|
36
39
|
|
|
37
40
|
function loadWatch() {
|
|
38
41
|
const list = (d, k) => (Array.isArray(d[k]) ? d[k] : []);
|
|
@@ -229,10 +232,53 @@ function watchBoard(hours = 24, now = Date.now()) {
|
|
|
229
232
|
const recent = d.news.filter((n) => n.watch === w.id && n.ts >= since && unseen(n, w)), k = kindOf(w.url), count = recent.length;
|
|
230
233
|
const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since && unseen(x, w));
|
|
231
234
|
return { ...view(w), source: sourceOf(w.url), count, label: k ? `${count} ${k[count === 1 ? 0 : 1]}` : `${count} new`,
|
|
232
|
-
items: markNews(recent.slice(0, 8), [w]), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}) };
|
|
235
|
+
items: markNews(recent.slice(0, 8), [w]), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}), ...(w.chat && w.chat.length ? { chat: w.chat } : {}) };
|
|
233
236
|
});
|
|
234
237
|
return { hours, total: cards.reduce((s, c) => s + c.count, 0), cards, brief: briefOn() };
|
|
235
238
|
}
|
|
239
|
+
// The board as one line, for a status bar with no jq: "2 emails · 1 WhatsApp
|
|
240
|
+
// message", the cards with something new; "" when none has.
|
|
241
|
+
function boardLine(b) { return b.cards.filter((c) => c.count).map((c) => (c.source === "page" ? `${c.count} new on ${c.name}` : c.label)).join(" · "); }
|
|
242
|
+
|
|
243
|
+
// ---- talk it over: a chat on each Dashboard card ----------------------------------
|
|
244
|
+
// You and your AI go over what's new on one card (its brief and the newest
|
|
245
|
+
// items) before anything's drafted: what matters, what to say to whom. The talk
|
|
246
|
+
// is kept on the watch, and a reply drafted from that card brings it along
|
|
247
|
+
// (talkOf), so the agent writes what you agreed. Grounded in what the card
|
|
248
|
+
// lists, like the brief: for mail, the sender, subject and Gmail's preview.
|
|
249
|
+
const TALK_KEEP = 40; // messages kept per card
|
|
250
|
+
async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write } = {}) {
|
|
251
|
+
question = String(question || "").trim().slice(0, 2000);
|
|
252
|
+
const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` };
|
|
253
|
+
if (!question) return { error: "empty" };
|
|
254
|
+
if (ask === write && !resolveProvider()) return { error: "not-connected" };
|
|
255
|
+
const since = now - hours * 3600000, recent = d.news.filter((n) => n.watch === w.id && n.ts >= since).slice(0, 25);
|
|
256
|
+
const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since);
|
|
257
|
+
const system = `You help someone go over what's new on a page they watch (their email inbox, their WhatsApp chats or their GitHub notifications), so they can agree what needs them and what to reply, before their coding agent drafts the replies. ` +
|
|
258
|
+
`Answer in plain text, briefly, no preamble and no markdown. Name things by sender and subject so they can find them. Use only what's listed; never guess at what a message says beyond it, and say so when the list doesn't settle it. ` +
|
|
259
|
+
`When they say how to answer one, say back in a line or two what that reply should say: it's handed to their agent to draft, and they send it themselves.`;
|
|
260
|
+
const history = (w.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`).join("\n\n");
|
|
261
|
+
const prompt = `${w.name}, new in the last ${hours} hours (newest first):\n${recent.length ? recent.map((n) => `- ${new Date(n.ts).toLocaleString()}: ${n.text}`).join("\n") : "(nothing new)"}\n\n` +
|
|
262
|
+
(b ? `The brief:\n${b.text}\n\n` : "") + (history ? `Conversation so far:\n${history}\n\n` : "") + `Question: ${question}`;
|
|
263
|
+
const said = String((await ask(system, prompt)) || "").trim();
|
|
264
|
+
const answer = said && !/^\(?couldn't reach the model/i.test(said) ? said.slice(0, 4000) : "(couldn't reach the model)";
|
|
265
|
+
// read again: a check may have saved the file while the model answered
|
|
266
|
+
const d2 = loadWatch(), w2 = d2.watches.find((x) => x.id === id); if (!w2) return { answer, chat: [] };
|
|
267
|
+
w2.chat = [...(w2.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-TALK_KEEP);
|
|
268
|
+
saveWatch(d2);
|
|
269
|
+
return { answer, chat: w2.chat };
|
|
270
|
+
}
|
|
271
|
+
function clearBoardChat(id) { const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` }; delete w.chat; saveWatch(d); return { ok: true }; }
|
|
272
|
+
// The card's talk, for a draft's brief: "" when there's been none.
|
|
273
|
+
function talkOf(w) {
|
|
274
|
+
const chat = (w.chat || []).slice(-16); if (!chat.length) return "";
|
|
275
|
+
return `## What the user said about it
|
|
276
|
+
The user went over what's new on ${w.name} with their AI in Symbiot's Dashboard before asking for this reply. Write the reply the way they agreed there for this message; what's about other messages isn't for this one. It's their own words, so follow it as you would the user (it's not part of the message you're replying to).
|
|
277
|
+
|
|
278
|
+
${chat.map((m) => `> **${m.role === "user" ? "User" : "AI"}:** ${String(m.text).replace(/\s+/g, " ")}`).join("\n>\n")}
|
|
279
|
+
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
236
282
|
|
|
237
283
|
// A batch of news as one notification: how many and where, then the brief if
|
|
238
284
|
// there is one, else the first few. The phone (phone.mjs) shows the same.
|
|
@@ -272,7 +318,7 @@ As the inbox listed it (the sender, the subject and the start of the message), n
|
|
|
272
318
|
|
|
273
319
|
> ${n.text.replace(/\s+/g, " ")}
|
|
274
320
|
|
|
275
|
-
## Tasks
|
|
321
|
+
${talkOf(w)}## Tasks
|
|
276
322
|
- [ ] Draft a reply to: ${short}
|
|
277
323
|
|
|
278
324
|
## How
|
|
@@ -314,7 +360,7 @@ As WhatsApp listed it (who it's from and the start of their last message), new o
|
|
|
314
360
|
|
|
315
361
|
> ${n.text.replace(/\s+/g, " ")}
|
|
316
362
|
|
|
317
|
-
## Tasks
|
|
363
|
+
${talkOf(w)}## Tasks
|
|
318
364
|
- [ ] Draft a reply to: ${short}
|
|
319
365
|
|
|
320
366
|
## How
|
|
@@ -364,6 +410,18 @@ function draftReply(id, { run = runHandoff } = {}) {
|
|
|
364
410
|
const d2 = loadWatch(), n2 = d2.news.find((x) => x.id === id); if (n2) { n2.drafted = Date.now(); saveWatch(d2); }
|
|
365
411
|
return { ok: true, job: e.id, dir, ...(chat ? { chat: true } : {}) };
|
|
366
412
|
}
|
|
413
|
+
// Open in WhatsApp, on a chat whose reply was drafted: Symbiot's browser opens
|
|
414
|
+
// web.whatsapp.com as a window (headless.mjs signIn), where the chat shows the
|
|
415
|
+
// reply in its box, for you to read and send. Not while the agent is still
|
|
416
|
+
// typing it: the window takes the browser it works in. `open` is signIn (the
|
|
417
|
+
// tests pass their own).
|
|
418
|
+
async function openChat(id, { open = signIn } = {}) {
|
|
419
|
+
const d = loadWatch(), n = d.news.find((x) => x.id === id); if (!n) return { error: "That message isn't under Watching any more." };
|
|
420
|
+
const w = d.watches.find((x) => x.id === n.watch);
|
|
421
|
+
if (!w || !isChat(w.url)) return { error: "Open in WhatsApp is for a WhatsApp chat." };
|
|
422
|
+
if (runningHandoff(join(DRAFTS_DIR, n.id))) return { error: "Your agent is still typing the reply. Open WhatsApp once it's done: the Agents tab shows when." };
|
|
423
|
+
return open(w.url);
|
|
424
|
+
}
|
|
367
425
|
|
|
368
426
|
// Read a watched page now and note what's new. `read`, `github`, `notify` and
|
|
369
427
|
// `brief` are headless.mjs's readPage, readGitHub, a desktop notification and
|
|
@@ -423,4 +481,4 @@ function startWatches(opts = {}) {
|
|
|
423
481
|
return () => { clearTimeout(first); clearInterval(every); };
|
|
424
482
|
}
|
|
425
483
|
|
|
426
|
-
export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, checkWatch, dueWatches, startWatches };
|
|
484
|
+
export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, clearBoardChat, talkOf, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };
|
package/writeups.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
6
6
|
import { VERSION, loadConfig, saveConfig, loadTasks, saveTasks, sh, repoState } from "./core.mjs";
|
|
7
7
|
import { resolveProvider, write } from "./ai.mjs";
|
|
8
8
|
import { detectMailSources, mailActivity } from "./mail.mjs";
|
|
9
|
-
import { waitingOn } from "./watch.mjs";
|
|
9
|
+
import { waitingOn, newsSince } from "./watch.mjs";
|
|
10
10
|
import { me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, commits, openWork, detectFolder, repoPathMap, discoveredRepos } from "./scan.mjs";
|
|
11
11
|
import { taskType, workingChanges, workingDiff } from "./tasks.mjs";
|
|
12
12
|
|
|
@@ -195,6 +195,15 @@ const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x
|
|
|
195
195
|
const WAITING_HOURS = 24;
|
|
196
196
|
function waitingLine(groups) { return groups.length ? "Waiting on you: " + groups.map((g) => g.label).join(", ") : ""; }
|
|
197
197
|
const withWaiting = (text, line) => (line ? `${text}\n\n${line}` : text);
|
|
198
|
+
// What arrived on the sites you've linked or watch (mail, chat, tickets,
|
|
199
|
+
// notifications) in the last `days`, per site, newest first: [{ name, count,
|
|
200
|
+
// items }]. For Week, so the update covers all of your work, not only your
|
|
201
|
+
// commits, and someone who doesn't write code gets one too.
|
|
202
|
+
function arrivedOn(days, now = Date.now()) {
|
|
203
|
+
const by = new Map();
|
|
204
|
+
for (const n of newsSince(days * 24, now)) { const g = by.get(n.name) || { name: n.name, count: 0, items: [] }; g.count++; if (g.items.length < 15) g.items.push(n.text); by.set(n.name, g); }
|
|
205
|
+
return [...by.values()].sort((a, b) => b.count - a.count);
|
|
206
|
+
}
|
|
198
207
|
|
|
199
208
|
// Build a write-up for a command; returns { text, sub, error? } without printing.
|
|
200
209
|
// Shared by the CLI (cmdRun) and the web UI (symbiot app). since: the days
|
|
@@ -221,31 +230,34 @@ async function produce(cmd, { since = 7, all: everyone = false } = {}) {
|
|
|
221
230
|
const mail = sentMail(days); // [] unless email is switched on
|
|
222
231
|
const waiting = label === "standup" ? waitingOn(WAITING_HOURS) : [], line = waitingLine(waiting);
|
|
223
232
|
const waitNote = waiting.length ? " · " + waiting.map((g) => g.label).join(", ") + " waiting" : "";
|
|
224
|
-
|
|
233
|
+
const arrived = label === "week" ? arrivedOn(days) : [], arrivedN = arrived.reduce((s, g) => s + g.count, 0);
|
|
234
|
+
if (!repos.length && !mail.length && !arrived.length) return { text: withWaiting(`No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, line), sub: "no activity" + waitNote };
|
|
225
235
|
let cs = commits(repos, `${days} days ago`, !everyone);
|
|
226
236
|
if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
|
|
227
237
|
const open = label === "week" ? openWork(repos) : [];
|
|
228
|
-
if (!cs.length && !mail.length) return { text: withWaiting("Found repos, but no commits in the window.", line), sub: "no commits" + waitNote };
|
|
238
|
+
if (!cs.length && !mail.length && !arrived.length) return { text: withWaiting("Found repos, but no commits in the window.", line), sub: "no commits" + waitNote };
|
|
229
239
|
|
|
230
240
|
const system =
|
|
231
|
-
`You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
|
|
241
|
+
`You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}${arrived.length ? ", and what arrived on the work sites they've linked (mail, chat, tickets, notifications)" : ""}. ` +
|
|
232
242
|
`Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
|
|
233
243
|
`Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
|
|
234
244
|
(mail.length ? `Fold the emails into those themes (a decision, a hand-off, who they worked with); skip routine ones (receipts, scheduling, one-line replies). You only have their subjects and recipients — don't guess at what they said. ` : "") +
|
|
245
|
+
(arrived.length ? `What arrived is titles and senders only, and it came TO them: use it to show what they dealt with and who they worked with, but don't claim they did something unless it shows it (a "merged", "approved" or "sent" line, a commit, an email they sent). Skip noise (newsletters, receipts, routine alerts). ${cs.length ? "" : "They may not write code: with no commits, write the update from these sites. "}` : "") +
|
|
235
246
|
`${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
|
|
236
|
-
`No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""} and open items show.` +
|
|
247
|
+
`No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""}${arrived.length ? ", linked sites" : ""} and open items show.` +
|
|
237
248
|
(waiting.length ? ` What's waiting on them (new on pages they watch) is counted in a line added under what you write: don't list or count it, but you may name one as next if it's clearly work (a review request, a failed CI run).` : "");
|
|
238
249
|
const prompt =
|
|
239
250
|
`Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
|
|
240
251
|
`Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
|
|
241
252
|
(mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
|
|
242
253
|
(open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
|
|
254
|
+
(arrived.length ? `What arrived on my linked sites (site: count, then titles):\n${arrived.map((g) => `- ${g.name}: ${g.count}\n${g.items.map((t) => ` - ${t.slice(0, 160)}`).join("\n")}`).join("\n")}\n\n` : "") +
|
|
243
255
|
(waiting.length ? `Waiting on me (new since yesterday):\n${waiting.map((g) => `- ${g.label}:\n${g.items.slice(0, 10).map((t) => ` - ${t.slice(0, 160)}`).join("\n")}`).join("\n")}\n\n` : "") +
|
|
244
256
|
`Write the ${label === "standup" ? "standup" : "update"}.`;
|
|
245
257
|
|
|
246
258
|
const text = await write(system, prompt);
|
|
247
|
-
const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
|
|
259
|
+
const mailNote = (mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "") + (arrivedN ? ` · ${arrivedN} from linked sites` : "");
|
|
248
260
|
return { text: text ? withWaiting(text, line) : "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote}${waitNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
|
|
249
261
|
}
|
|
250
262
|
|
|
251
|
-
export { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce };
|
|
263
|
+
export { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, arrivedOn, produce };
|