symbiot 0.41.6 → 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/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). A brief is your AI's read of one
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
- const MAX_WATCHES = 12, MAX_SEEN = 1000, MAX_NEWS = 200, MAX_PER_READ = 25, MAX_BRIEFS = 50;
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
- if (!repos.length && !mail.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 };
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 };