symbiot 0.40.4 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/watch.mjs CHANGED
@@ -7,26 +7,34 @@
7
7
  // An agent reads what's new with `symbiot watch new`, and can act on it with
8
8
  // `symbiot screens press` / `type`.
9
9
  //
10
+ // GitHub's notifications (github.com/notifications) are read through `gh`
11
+ // when it's signed in, not the page: see readGitHub.
12
+ //
10
13
  // Stored in ~/.config/symbiot/watch.json, readable by you only:
11
- // { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, seen: [key…] }],
12
- // news: [{ id, watch, name, ts, text, href? }] }
14
+ // { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, via?, seen: [key…] }],
15
+ // news: [{ id, watch, name, ts, text, href? }],
16
+ // briefs: [{ id, watch, name, ts, count, text }] }
13
17
  // `last` is when it was last read, `checked` when a read last worked; `seen`
14
- // holds what's been listed, as itemKey()s.
18
+ // holds what's been listed, as itemKey()s. A brief is your AI's read of one
19
+ // batch of news (config.watchBrief switches it on), with the same `ts`.
15
20
  import { join } from "node:path";
16
21
  import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
22
+ import { execFile } from "node:child_process";
17
23
  import { randomBytes } from "node:crypto";
18
- import { CONFIG_DIR } from "./core.mjs";
24
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
19
25
  import { readPage, siteUrl } from "./headless.mjs";
20
26
  import { loadScreens } from "./screens.mjs";
21
27
  import { desktopNotify } from "./desktop.mjs";
28
+ import { resolveProvider, write } from "./ai.mjs";
22
29
 
23
30
  const WATCH_FILE = join(CONFIG_DIR, "watch.json");
24
31
  const EVERY = [5, 15, 30, 60]; // minutes between reads
25
- const MAX_WATCHES = 12, MAX_SEEN = 1000, MAX_NEWS = 200, MAX_PER_READ = 25;
32
+ const MAX_WATCHES = 12, MAX_SEEN = 1000, MAX_NEWS = 200, MAX_PER_READ = 25, MAX_BRIEFS = 50;
26
33
 
27
34
  function loadWatch() {
28
- try { const d = JSON.parse(readFileSync(WATCH_FILE, "utf8")); return { watches: Array.isArray(d.watches) ? d.watches : [], news: Array.isArray(d.news) ? d.news : [] }; }
29
- catch { return { watches: [], news: [] }; }
35
+ const list = (d, k) => (Array.isArray(d[k]) ? d[k] : []);
36
+ try { const d = JSON.parse(readFileSync(WATCH_FILE, "utf8")); return { watches: list(d, "watches"), news: list(d, "news"), briefs: list(d, "briefs") }; }
37
+ catch { return { watches: [], news: [], briefs: [] }; }
30
38
  }
31
39
  // What's new can be your mail's senders and subjects, so it's yours only (0600).
32
40
  function saveWatch(d) {
@@ -65,10 +73,11 @@ function itemKey(text) {
65
73
  return s.replace(/[\s,;·•|]+/g, " ").trim().slice(0, 240);
66
74
  }
67
75
  // The items not seen before, top first, and the keys of everything listed now.
76
+ // An item can bring its own key (GitHub's: its thread and when it changed).
68
77
  function newItems(seen, items) {
69
78
  const known = new Set(seen), keys = new Set(), fresh = [];
70
79
  for (const it of items) {
71
- const k = itemKey(it.text); if (!k || keys.has(k)) continue;
80
+ const k = it.key || itemKey(it.text); if (!k || keys.has(k)) continue;
72
81
  keys.add(k); if (!known.has(k)) fresh.push(it);
73
82
  }
74
83
  return { fresh, keys: [...keys] };
@@ -76,22 +85,76 @@ function newItems(seen, items) {
76
85
  // Remember what's listed now as the most recent, and forget the oldest.
77
86
  function remember(seen, keys) { const now = new Set(keys); return [...(seen || []).filter((k) => !now.has(k)), ...keys].slice(-MAX_SEEN); }
78
87
 
88
+ // ---- GitHub, through gh --------------------------------------------------------
89
+ // Review requests, failed CI runs and mentions read better from GitHub's API than
90
+ // from its page: with the GitHub CLI signed in (gh auth login), Symbiot asks it
91
+ // for your unread notifications. No browser, no Sign in. Without gh, the page is
92
+ // read like any other.
93
+ const GITHUB_INBOX = "https://github.com/notifications";
94
+ const isGitHubInbox = (url) => /^https:\/\/(www\.)?github\.com\/notifications(?:[/?#]|$)/i.test(String(url || ""));
95
+ const REASON = { review_requested: "Review requested", approval_requested: "Approval requested", mention: "Mentioned", team_mention: "Team mentioned", assign: "Assigned", security_alert: "Security alert", invitation: "Invitation", comment: "New comment", state_change: "Changed" };
96
+ // The page on github.com for a notification (its subject's url is the API's).
97
+ function githubHref(n) {
98
+ const repo = (n.repository && n.repository.html_url) || "", api = String((n.subject && n.subject.url) || "");
99
+ const m = api.match(/^https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+)\/(pulls|issues|commits|releases)\/([^/]+)$/);
100
+ if (m) return m[2] === "releases" ? `https://github.com/${m[1]}/releases` : `https://github.com/${m[1]}/${{ pulls: "pull", issues: "issues", commits: "commit" }[m[2]]}/${m[3]}`;
101
+ if (n.subject && /^(CheckSuite|WorkflowRun)$/.test(n.subject.type) && repo) return repo + "/actions";
102
+ return repo || GITHUB_INBOX;
103
+ }
104
+ // GET /notifications's answer as items. Each is keyed by its thread and when it
105
+ // last changed: a CI run failing again on the same branch, or a new comment on a
106
+ // pull request, is new again.
107
+ function githubItems(list) {
108
+ return (Array.isArray(list) ? list : []).filter((n) => n && n.id && n.subject && n.subject.title).map((n) => {
109
+ const why = n.reason === "ci_activity" ? "" : REASON[n.reason] || "";
110
+ const repo = (n.repository && n.repository.full_name) || "";
111
+ return { text: [why, repo, n.subject.title].filter(Boolean).join(" · ").slice(0, 300), href: githubHref(n), key: `github ${n.id} ${n.updated_at || ""}` };
112
+ });
113
+ }
114
+ // Your unread notifications through gh, as a page Watch reads ({ url, list,
115
+ // via }), or null when gh isn't there or isn't signed in (then the page is read).
116
+ function readGitHub() {
117
+ return new Promise((resolve) => {
118
+ execFile("gh", ["api", "notifications?per_page=50"], { timeout: 30000, maxBuffer: 8 << 20, windowsHide: true }, (err, out) => {
119
+ if (err) return resolve(null);
120
+ try { resolve({ url: GITHUB_INBOX, list: githubItems(JSON.parse(out)), via: "gh" }); } catch { resolve(null); }
121
+ });
122
+ });
123
+ }
124
+
125
+ // ---- the brief -------------------------------------------------------------------
126
+ // Switched on, your AI reads each batch of news (what the page lists: for mail,
127
+ // the sender, subject and Gmail's one-line preview) and says what needs you and
128
+ // what can wait. That's in the app and in the notification. A local Ollama model
129
+ // keeps it all on this computer.
130
+ const briefOn = () => !!loadConfig().watchBrief;
131
+ function setBrief(on) { const cfg = loadConfig(); if (on) cfg.watchBrief = true; else delete cfg.watchBrief; return saveConfig(cfg) ? { brief: !!on } : { error: "Couldn't write the config file." }; }
132
+ async function briefOf(news, name) {
133
+ if (!resolveProvider()) return "";
134
+ const system = `You triage what just arrived on a page someone watches: their email inbox, or their GitHub notifications. ` +
135
+ `In at most 3 short lines of plain text, first what needs them (and why, in a few words), then what can wait. ` +
136
+ `Name things by sender and subject so they can find them. Use only what's shown; never guess at what a message says beyond it. No preamble, no markdown.`;
137
+ const text = await write(system, `New on ${name}:\n${news.map((n) => "- " + n.text).join("\n")}\n\nWhat needs me, and what can wait?`);
138
+ return text && !/^\(?couldn't reach the model/i.test(text) ? text.trim().slice(0, 600) : "";
139
+ }
140
+
79
141
  // ---- watches -------------------------------------------------------------------
80
- const view = (w) => ({ id: w.id, name: w.name, url: w.url, every: w.every, added: w.added, last: w.last || 0, ...(w.checked ? { checked: w.checked } : {}), ...(w.error ? { error: w.error } : {}), known: (w.seen || []).length });
81
- function watchState() { const d = loadWatch(); return { watches: d.watches.map(view), news: d.news.slice(0, 50), every: EVERY }; }
142
+ const view = (w) => ({ id: w.id, name: w.name, url: w.url, every: w.every, added: w.added, last: w.last || 0, ...(w.checked ? { checked: w.checked } : {}), ...(w.error ? { error: w.error } : {}), ...(w.via ? { via: w.via } : {}), known: (w.seen || []).length });
143
+ function watchState() { const d = loadWatch(); return { watches: d.watches.map(view), news: d.news.slice(0, 50), briefs: d.briefs.slice(0, 10), brief: briefOn(), every: EVERY }; }
82
144
 
83
- // Watch a mapped page (`screen`: its id) or a site (`site`). Watching the same
84
- // address again just changes how often.
145
+ // Watch a mapped page (`screen`: its id) or a site (`site`; "github" is GitHub's
146
+ // notifications). Watching the same address again just changes how often.
85
147
  function addWatch({ screen, site, every, name } = {}) {
86
148
  const s = screen ? loadScreens().find((x) => x.id === screen) : null;
87
149
  if (screen && !s) return { error: `No screen ${screen}.` };
88
150
  if (s && !s.page) return { error: "Watch reads a web page you mapped (Map page). A screenshot of your screen can't be read again by itself." };
89
- const url = s ? s.page.url : siteUrl(site);
90
- if (!url) return { error: "Give a mapped page or a site to watch: gmail, github.com/notifications or a web address." };
151
+ const url = s ? s.page.url : /^\s*(https?:\/\/)?(www\.)?github(\.com)?\/?\s*$/i.test(String(site || "")) ? GITHUB_INBOX : siteUrl(site);
152
+ if (!url) return { error: "Give a mapped page or a site to watch: gmail, github or a web address." };
91
153
  const d = loadWatch(), had = d.watches.find((w) => w.url === url);
92
154
  if (had) { had.every = everyOf(every, had.every); return saveWatch(d) ? view(had) : { error: "Couldn't write " + WATCH_FILE + "." }; }
93
155
  if (d.watches.length >= MAX_WATCHES) return { error: `You're watching ${MAX_WATCHES} pages already. Stop watching one first.` };
94
- const w = { id: randomBytes(4).toString("hex"), name: String(name || (s && s.name) || hostOf(url)).trim().slice(0, 80) || hostOf(url), url, every: everyOf(every, 15), added: Date.now(), last: 0, seen: [] };
156
+ const fallback = isGitHubInbox(url) ? "GitHub notifications" : hostOf(url);
157
+ const w = { id: randomBytes(4).toString("hex"), name: String(name || (s && s.name) || fallback).trim().slice(0, 80) || fallback, url, every: everyOf(every, 15), added: Date.now(), last: 0, seen: [] };
95
158
  d.watches.push(w);
96
159
  return saveWatch(d) ? view(w) : { error: "Couldn't write " + WATCH_FILE + "." };
97
160
  }
@@ -103,42 +166,84 @@ function setEvery(id, every) {
103
166
  // Stop watching, and forget what it found.
104
167
  function removeWatch(id) {
105
168
  const d = loadWatch(); if (!d.watches.some((x) => x.id === id)) return { error: `No watch ${id}.` };
106
- d.watches = d.watches.filter((x) => x.id !== id); d.news = d.news.filter((n) => n.watch !== id);
169
+ d.watches = d.watches.filter((x) => x.id !== id); d.news = d.news.filter((n) => n.watch !== id); d.briefs = d.briefs.filter((n) => n.watch !== id);
107
170
  saveWatch(d); return { ok: true };
108
171
  }
109
- function clearNews() { const d = loadWatch(); d.news = []; saveWatch(d); return { ok: true }; }
172
+ function clearNews() { const d = loadWatch(); d.news = []; d.briefs = []; saveWatch(d); return { ok: true }; }
110
173
  // What's new in the last `hours`, newest first (what an agent reads).
111
174
  function newsSince(hours = 24, now = Date.now()) { return loadWatch().news.filter((n) => n.ts >= now - hours * 3600000); }
175
+ // What's new after `ts` (exclusive), and its briefs: what the phone asks for.
176
+ function newsAfter(ts) { const d = loadWatch(), t = Number(ts) || 0; return { news: d.news.filter((n) => n.ts > t), briefs: d.briefs.filter((b) => b.ts > t) }; }
112
177
 
113
- // Read a watched page now and note what's new. `read` and `notify` are
114
- // headless.mjs's readPage and a desktop notification (the tests pass their own).
115
- // Gives the watch, plus `new` (what was new) or `learned` (the first read), or
116
- // `busy` when the hidden browser is in use (nothing changes: it's read later).
117
- async function checkWatch(id, { read = readPage, notify = desktopNotify } = {}) {
178
+ // ---- for Standup: "3 emails waiting on you" ------------------------------------
179
+ // What a watch lists, as a noun: mail, GitHub notifications, else new things on it.
180
+ function kindOf(url) {
181
+ const h = hostOf(url);
182
+ if (isGitHubInbox(url)) return ["GitHub notification", "GitHub notifications"];
183
+ if (/^(mail|webmail)\.|^outlook\.(live|office|office365)\.com$/.test(h)) return ["email", "emails"];
184
+ return null;
185
+ }
186
+ // What's new on each page you watch in the last `hours`: [{ name, count, label,
187
+ // items }], label like "3 emails" or "2 new on Jira".
188
+ function waitingOn(hours = 24, now = Date.now()) {
189
+ const d = loadWatch(), out = new Map();
190
+ for (const n of d.news.filter((x) => x.ts >= now - hours * 3600000)) {
191
+ const w = d.watches.find((x) => x.id === n.watch); if (!w) continue;
192
+ const g = out.get(w.id) || { name: w.name, url: w.url, items: [] }; g.items.push(n.text); out.set(w.id, g);
193
+ }
194
+ return [...out.values()].map((g) => {
195
+ const k = kindOf(g.url), count = g.items.length;
196
+ return { name: g.name, count, label: k ? `${count} ${k[count === 1 ? 0 : 1]}` : `${count} new on ${g.name}`, items: g.items };
197
+ });
198
+ }
199
+
200
+ // A batch of news as one notification: how many and where, then the brief if
201
+ // there is one, else the first few. The phone (phone.mjs) shows the same.
202
+ function newsNotice(news, name, brief = "") {
203
+ return [`${news.length} new · ${String(name).slice(0, 50)}`,
204
+ brief || news.slice(0, 3).map((n) => n.text.slice(0, 90)).join("\n") + (news.length > 3 ? `\n…and ${news.length - 3} more in Symbiot` : "")];
205
+ }
206
+
207
+ // Read a watched page now and note what's new. `read`, `github`, `notify` and
208
+ // `brief` are headless.mjs's readPage, readGitHub, a desktop notification and
209
+ // briefOf (the tests pass their own). Gives the watch, plus `new` (what was new)
210
+ // and `brief`, or `learned` (the first read), or `busy` when the hidden browser
211
+ // is in use (nothing changes: it's read later).
212
+ async function checkWatch(id, { read = readPage, github = readGitHub, notify = desktopNotify, brief = briefOf } = {}) {
118
213
  const w0 = loadWatch().watches.find((x) => x.id === id); if (!w0) return { error: `No watch ${id}.` };
119
- const page = (await read(w0.url)) || { error: "Nothing came back from the page." };
214
+ const page = (isGitHubInbox(w0.url) && await github()) || (await read(w0.url)) || { error: "Nothing came back from the page." };
120
215
  if (page.busy) return { ...view(w0), busy: true };
121
216
  // read again: the file may have changed while the page loaded
122
217
  const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: "That watch was removed." };
123
218
  w.last = Date.now();
124
- const items = page.error || page.login ? [] : itemsOf(page), before = w.error || "";
125
- if (page.login) w.error = `Signed out of ${hostOf(page.url) || hostOf(w.url)}. Under Screens, type ${hostOf(w.url)} and click Sign in, sign in once and close the window.`;
219
+ // through gh, a list with nothing unread is a good read; a page always lists something
220
+ const items = page.error || page.login ? [] : page.list || itemsOf(page), before = w.error || "";
221
+ if (page.login) w.error = `Signed out of ${hostOf(page.url) || hostOf(w.url)}. Under Screens, type ${hostOf(w.url)} and click Sign in, sign in once and close the window.` + (isGitHubInbox(w.url) ? " Or sign in the GitHub CLI (gh auth login): Symbiot then reads your notifications through it." : "");
126
222
  else if (page.error) w.error = page.error;
127
- else if (!items.length) w.error = "Found nothing listed on the page this time.";
128
- if (page.error || page.login || !items.length) {
223
+ else if (!items.length && !page.list) w.error = "Found nothing listed on the page this time.";
224
+ if (page.error || page.login || (!items.length && !page.list)) {
129
225
  saveWatch(d);
130
226
  // signed out needs you; the rest (the browser busy with Sign in, a slow page) usually passes by itself
131
227
  if (page.login && w.error !== before) notify("Symbiot", `${w.name}: ${w.error}`);
132
228
  return view(w);
133
229
  }
134
230
  delete w.error;
135
- const first = !w.checked, { fresh, keys } = newItems(w.seen, items);
231
+ // read another way than last time (gh, or the page while gh is out): the same
232
+ // things look different, so learn them again rather than announce them all
233
+ const first = !w.checked || (w.via || "") !== (page.via || ""), { fresh, keys } = newItems(w.seen, items);
234
+ if (page.via) w.via = page.via; else delete w.via;
136
235
  w.seen = remember(w.seen, keys); w.checked = w.last;
137
236
  const news = first ? [] : fresh.slice(0, MAX_PER_READ).map((it) => ({ id: randomBytes(4).toString("hex"), watch: w.id, name: w.name, ts: w.last, text: it.text.slice(0, 300), ...(it.href ? { href: it.href } : {}) }));
138
237
  d.news = [...news, ...d.news].slice(0, MAX_NEWS);
139
238
  saveWatch(d);
140
- if (news.length) notify(`${news.length} new · ${w.name.slice(0, 50)}`, news.slice(0, 3).map((n) => n.text.slice(0, 90)).join("\n") + (news.length > 3 ? `\n…and ${news.length - 3} more in Symbiot` : ""));
141
- return { ...view(w), ...(first ? { learned: keys.length } : { new: news }) };
239
+ if (first) return { ...view(w), learned: keys.length };
240
+ let said = "";
241
+ if (news.length && briefOn()) {
242
+ said = await brief(news, w.name).catch(() => "");
243
+ if (said) { const d2 = loadWatch(); d2.briefs = [{ id: randomBytes(4).toString("hex"), watch: w.id, name: w.name, ts: w.last, count: news.length, text: said }, ...d2.briefs].slice(0, MAX_BRIEFS); saveWatch(d2); }
244
+ }
245
+ if (news.length) notify(...newsNotice(news, w.name, said));
246
+ return { ...view(w), new: news, ...(said ? { brief: said } : {}) };
142
247
  }
143
248
  // The watches that are due, longest-waiting first.
144
249
  function dueWatches(now = Date.now()) { return loadWatch().watches.filter((w) => now - (w.last || 0) >= w.every * 60000).sort((a, b) => (a.last || 0) - (b.last || 0)); }
@@ -157,4 +262,4 @@ function startWatches(opts = {}) {
157
262
  return () => { clearTimeout(first); clearInterval(every); };
158
263
  }
159
264
 
160
- export { WATCH_FILE, EVERY, itemsOf, itemKey, newItems, remember, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, checkWatch, dueWatches, startWatches };
265
+ export { WATCH_FILE, EVERY, GITHUB_INBOX, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, newsAfter, waitingOn, checkWatch, dueWatches, startWatches };
package/writeups.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // What Symbiot writes with your AI: your week, standup and to-dos, a repo's
2
+ // review and next steps, a folder's overview, and the Q&A on a task. Plus the
3
+ // opt-in sent mail that week and standup fold in (read by mail.mjs).
4
+ import { join } from "node:path";
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { VERSION, loadConfig, saveConfig, loadTasks, saveTasks, sh, repoState } from "./core.mjs";
7
+ import { resolveProvider, write } from "./ai.mjs";
8
+ import { detectMailSources, mailActivity } from "./mail.mjs";
9
+ import { waitingOn } from "./watch.mjs";
10
+ import { me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, commits, openWork, detectFolder, repoPathMap, discoveredRepos } from "./scan.mjs";
11
+ import { taskType, workingChanges, workingDiff } from "./tasks.mjs";
12
+
13
+ // ---- render ---------------------------------------------------------------
14
+ function renderCommits(list) {
15
+ return list.map((x) => `- [${x.repo}] ${x.subject}${x.files.length ? ` (${x.files.length} files)` : ""}`).join("\n");
16
+ }
17
+
18
+ // AI review of one repo: what it does, what it's for, possible upgrades.
19
+ async function repoReview(path) {
20
+ if (!resolveProvider()) return { error: "not-connected" };
21
+ if (!path) return { error: "no repo" };
22
+ const name = path.split("/").pop();
23
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), shape = repoShape(path), rules = houseRules(path);
24
+ const commits = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
25
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
26
+ const footer = reportFooter(path, auth, state, rd);
27
+ // Cheap maturity gauge, so the model can be told to show restraint instead of
28
+ // always inventing new features (over-engineering is a failure, not a win).
29
+ const testFiles = Number(sh(`git -C ${JSON.stringify(path)} ls-files 2>/dev/null | grep -icE '(^|/)(tests?|spec|__tests__)/|\\.(test|spec)\\.'`).trim()) || 0;
30
+ const churn = Number(sh(`git -C ${JSON.stringify(path)} log --since='14 days ago' --oneline 2>/dev/null | wc -l`).trim()) || 0;
31
+ const posture = state.stale ? "the checkout is a stale snapshot — the priority is getting current, not new work"
32
+ : (testFiles === 0 && shape.total > 40) ? "a real codebase with NO test files — favour tests/stabilising over new features"
33
+ : (churn >= 25) ? "very rapid churn lately — likely over-iterating; be conservative, prefer consolidation/finishing over new features"
34
+ : (state.dirty > 20) ? "a lot of unfinished/uncommitted work — finish what's open before starting new things"
35
+ : "no red flags — but still only propose what clearly pays off";
36
+ const system =
37
+ `You are a pragmatic, restraint-minded reviewer of ONE software project. Respond with ONLY a JSON object (no fences): {"review": string, "verdict": string, "ideas": string[]}. ` +
38
+ `"review": 3-6 sentences on what it does and who it's for. ` +
39
+ `"verdict": ONE honest sentence on whether this project needs new work now, and of what KIND. Over-engineering is a failure: if it's already capable, or shows churn / missing tests / unfinished work, say so and steer toward STABILISING (tests, docs, finishing, removing) or simply SHIPPING — not more features. ` +
40
+ `"ideas": AT MOST 5, ranked by value, ONLY items that clearly pay off. Do NOT pad the list — returning 0–2 ideas with a "stabilise, don't add" verdict is a good, correct answer. Prefer fixes / tests / simplification / finishing over new features unless a feature is clearly warranted by the evidence. ` +
41
+ `Ground every claim in the evidence; recent commits beat the README; never propose building something already in the structure; never advise against the stated conventions. ` +
42
+ (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it. ` : ``);
43
+ const prompt =
44
+ `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n` +
45
+ `Maturity signal: ${posture}. (${testFiles} test files, ${churn} commits in the last 14 days.)\n\n` +
46
+ (rd.file ? `README (last changed ${rd.lastDate || "?"}, ${rd.commitsAgo} commits ago — may be out of date):\n${rd.excerpt}\n\n` : "(no README)\n\n") +
47
+ `Structure (top folders · file counts): ${shape.dirs.join(", ")}\nManifests: ${shape.manifests.join(", ") || "none"}\nDocs present: ${shape.docs.join(", ") || "none"}\n\n` +
48
+ `Recent commits (newest first):\n${commits || "(none)"}\n\n` +
49
+ (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
50
+ (state.stale ? `Working tree: STALE (≈ ${state.staleBy ? "HEAD~" + state.staleBy : "older"}); its "changes" are the gap to an old snapshot, not new work.\n\n` : "") +
51
+ `Return the JSON. Remember: fewer, higher-value ideas beat a long list; a "don't add — stabilise" verdict with 0–2 ideas is a valid answer.`;
52
+ const raw = await write(system, prompt);
53
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [], verdict: "", footer };
54
+ const j = extractJson(raw);
55
+ if (j && (j.review || j.ideas || j.verdict)) return {
56
+ text: String(j.review || "").trim() || raw,
57
+ verdict: String(j.verdict || "").trim(),
58
+ ideas: Array.isArray(j.ideas) ? j.ideas.map((x) => String(x).trim()).filter(Boolean).slice(0, 5) : [],
59
+ footer,
60
+ };
61
+ return { text: raw, ideas: [], verdict: "", footer };
62
+ }
63
+ function extractJson(s) {
64
+ if (!s) return null;
65
+ let t = String(s).trim().replace(/^```(?:json)?/i, "").replace(/```$/,"").trim();
66
+ const a = t.indexOf("{"), b = t.lastIndexOf("}");
67
+ if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
68
+ return null;
69
+ }
70
+ // AI "value" suggestions for one repo, from its recent commits + open work.
71
+ async function repoSuggest(path) {
72
+ if (!resolveProvider()) return { error: "not-connected" };
73
+ if (!path) return { error: "no repo" };
74
+ const name = path.split("/").pop();
75
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), rules = houseRules(path);
76
+ const recent = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
77
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
78
+ const open = openWork([{ path, name }]);
79
+ const footer = reportFooter(path, auth, state, rd);
80
+ const system =
81
+ `You are a pragmatic senior engineer advising on one project. Write exactly three short sections with headings: ` +
82
+ `"In flight" (what's clearly underway), "Next steps" (3-5 concrete actions), "Ideas" (2-3 that fit where this is heading). ` +
83
+ `Ground each point in the evidence; recent commits beat the README. Do not advise against the stated conventions. ` +
84
+ (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it (would revert history). ` : ``) +
85
+ `No filler, no preamble.`;
86
+ const prompt =
87
+ `Project: ${name} (branch ${state.branch}, ${auth.total} commits, ${auth.mineCount} yours${auth.filterDropped ? " — filter dropped" : ""}).\n\n` +
88
+ `Recent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\n` +
89
+ (rules ? `Conventions — do NOT advise against these:\n${rules}\n\n` : "") + `Give the advice.`;
90
+ const text = await write(system, prompt);
91
+ return { text: text || "(couldn't reach the model)", footer };
92
+ }
93
+ // Overview + suggestions for a NON-git project folder (no commits to read), from
94
+ // its file tree, manifest and README. The folder-node equivalent of repoSuggest.
95
+ async function folderSuggest(path) {
96
+ if (!resolveProvider()) return { error: "not-connected" };
97
+ if (!path) return { error: "no path" };
98
+ const det = detectFolder(path);
99
+ const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -200`).split("\n").filter(Boolean);
100
+ const rels = files.map((f) => f.slice(path.length + 1));
101
+ const pick = (names) => { for (const n of names) { const hit = files.find((f) => f.split("/").pop().toLowerCase() === n); if (hit) { try { return readFileSync(hit, "utf8").slice(0, 2000); } catch {} } } return ""; };
102
+ const manifest = pick(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "cargo.toml", "pom.xml", "composer.json", "gemfile", "pubspec.yaml"]);
103
+ const readme = pick(["readme.md", "readme.txt", "readme"]);
104
+ const stack = [...det.langs, ...det.tools].join(", ") || "unknown";
105
+ const system =
106
+ `You are a pragmatic senior engineer looking at a project FOLDER that is NOT under version control. ` +
107
+ `From its files, write exactly three short sections with headings: "Overview" (what this project is, 1-2 sentences), ` +
108
+ `"Next steps" (3-5 concrete actions — a strong first one is often "git init" if this looks like real work), ` +
109
+ `"Ideas" (2-3 that fit where it's heading). Ground every point in the actual files/manifest/README shown. No preamble.`;
110
+ const prompt =
111
+ `Folder: ${det.name}\nStack: ${stack}\nFiles (${det.files}):\n${rels.slice(0, 120).join("\n")}\n\n` +
112
+ (manifest ? `Manifest:\n${manifest}\n\n` : "") + (readme ? `README excerpt:\n${readme}\n\n` : "") + `Write the overview and suggestions.`;
113
+ const text = await write(system, prompt);
114
+ const footer = `symbiot ${VERSION} · folder · ${det.files} files · ${stack} · not a git repo`;
115
+ return { text: text || "(couldn't reach the model)", footer };
116
+ }
117
+ // Ask questions about a task: a short Q&A thread kept on the task itself
118
+ // (task.chat), grounded in the repo/folder it belongs to — and, once an agent
119
+ // has ticked it, in the changes waiting for review.
120
+ const CHAT_KEEP = 40; // messages stored per task
121
+ async function taskChat(id, question) {
122
+ question = String(question || "").trim().slice(0, 2000);
123
+ const it = loadTasks().find((x) => x.id === id);
124
+ if (!it) return { error: "not found" };
125
+ if (!question) return { error: "empty" };
126
+ if (!resolveProvider()) return { error: "not-connected" };
127
+ const path = it.repo ? repoPathMap()[it.repo] : "";
128
+ const ctx = [];
129
+ if (path) {
130
+ const isGit = existsSync(join(path, ".git"));
131
+ if (isGit) ctx.push(`Recent commits:\n${sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -15 2>/dev/null`).trim() || "(none)"}`);
132
+ else { const det = detectFolder(path); ctx.push(`Project folder (not a git repo) · ${det.files} files · ${[...det.langs, ...det.tools].join(", ") || "unknown stack"}`); }
133
+ const rd = readmeInfo(path); if (rd.excerpt) ctx.push(`README excerpt:\n${rd.excerpt.slice(0, 1500)}`);
134
+ const rules = houseRules(path); if (rules) ctx.push(`Conventions — do NOT advise against these:\n${rules}`);
135
+ if (it.review && isGit) {
136
+ const ch = workingChanges(path);
137
+ ctx.push(`The agent has ticked this task; its uncommitted changes await review (${ch.stat || "no changes"}):\n${ch.files.map((f) => `${f.st} ${f.file}`).slice(0, 40).join("\n") || "(none)"}\n\nDiff (truncated):\n${workingDiff(path, 6000)}`);
138
+ }
139
+ }
140
+ const history = (it.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`).join("\n\n");
141
+ const system =
142
+ `You are a pragmatic senior engineer helping someone with ONE task on their list — before or after they hand it to a coding agent. ` +
143
+ `Answer their question about the task directly and concisely, in plain text, no preamble. Ground what you say in the project evidence shown; ` +
144
+ `if the evidence doesn't settle it, say so and what you'd check. If the task is ambiguous, say how you'd read it and what to clarify. ` +
145
+ `Never invent files, features or history.`;
146
+ const prompt =
147
+ `Task: ${it.text}\nKind: ${taskType(it.text)} · repo: ${it.repo || "(none)"} · status: ${it.archived ? "archived" : it.review ? "done by the agent, awaiting review" : it.done ? "done" : "open"}\n\n` +
148
+ (ctx.length ? ctx.join("\n\n") + "\n\n" : path ? "" : "(no repo attached — answer from the task text alone)\n\n") +
149
+ (history ? `Conversation so far:\n${history}\n\n` : "") + `Question: ${question}`;
150
+ const answer = (await write(system, prompt)) || "(couldn't reach the model)";
151
+ // Re-read: other requests may have changed tasks.json while the model ran.
152
+ const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
153
+ if (!cur) return { answer, chat: [] };
154
+ cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-CHAT_KEEP);
155
+ saveTasks(t);
156
+ return { answer, chat: cur.chat };
157
+ }
158
+ function clearTaskChat(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" }; delete it.chat; saveTasks(t); return { ok: true }; }
159
+
160
+ // ---- email: opt-in, local, no API (see mail.mjs) ---------------------------
161
+ // config.mail = { enabled, sources: [extra folders/.mbox files], addresses: [your
162
+ // mail addresses, to pick your mail out of a whole-mailbox export] }.
163
+ function mailCfg() {
164
+ const m = loadConfig().mail || {};
165
+ return { enabled: !!m.enabled, sources: Array.isArray(m.sources) ? m.sources : [], addresses: Array.isArray(m.addresses) ? m.addresses : [] };
166
+ }
167
+ function mailState() { return { ...mailCfg(), detected: detectMailSources() }; }
168
+ function setMail(b = {}) {
169
+ const m = mailCfg();
170
+ if (typeof b.enabled === "boolean") m.enabled = b.enabled;
171
+ if (b.add) {
172
+ const p = expandRoot(b.add);
173
+ // A website typed here (it sits under Trusted sites, easily mixed up): say where it goes.
174
+ const site = p && !existsSync(p) && !/\.(mbox|mbx|eml|msf|sbd)$/i.test(p) && /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?([/?#].*)?$/i.test(p) ? new URL(/^https?:/i.test(p) ? p : "https://" + p).hostname : "";
175
+ if (site) return { ...mailState(), site, error: `${site} is a website, not mail on this computer. To let Screens press and type there without asking, add it under Trusted sites, just above.` };
176
+ if (!p || !existsSync(p)) return { ...mailState(), error: "not found: " + (p || "(empty)") };
177
+ if (!m.sources.includes(p)) m.sources.push(p);
178
+ }
179
+ if (b.remove) m.sources = m.sources.filter((x) => x !== b.remove);
180
+ if (typeof b.addresses === "string") m.addresses = b.addresses.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.includes("@"));
181
+ const cfg = loadConfig(); cfg.mail = m; saveConfig(cfg);
182
+ return mailState();
183
+ }
184
+ // Mail you sent in the window — [] unless it's switched on (preview ignores that).
185
+ function sentMail(days, preview = false) {
186
+ const m = mailCfg(); if (!m.enabled && !preview) return [];
187
+ return mailActivity({ days, sources: m.sources, addresses: [...m.addresses, me().email].filter(Boolean) });
188
+ }
189
+ const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x.to.join(", ") || "?"} · ${x.subject}`).join("\n");
190
+
191
+ // What's new on the pages you watch since yesterday (watch.mjs), for Standup:
192
+ // one line, "Waiting on you: 3 emails, 2 GitHub notifications", under the
193
+ // write-up. Counted here, not by the AI, so the numbers are right; the AI sees
194
+ // them only to know what's next.
195
+ const WAITING_HOURS = 24;
196
+ function waitingLine(groups) { return groups.length ? "Waiting on you: " + groups.map((g) => g.label).join(", ") : ""; }
197
+ const withWaiting = (text, line) => (line ? `${text}\n\n${line}` : text);
198
+
199
+ // Build a write-up for a command; returns { text, sub, error? } without printing.
200
+ // Shared by the CLI (cmdRun) and the web UI (symbiot app). since: the days
201
+ // `week` covers (--since); all: everyone's commits, not just yours (--all).
202
+ async function produce(cmd, { since = 7, all: everyone = false } = {}) {
203
+ if (!resolveProvider()) return { error: "not-connected" };
204
+ if (cmd === "todo") {
205
+ const repos = discoveredRepos();
206
+ const open = openWork(repos);
207
+ if (!open.length) return { text: "Nothing outstanding found (no TODOs or uncommitted work).", sub: "0 open items · todo" };
208
+ const system =
209
+ `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
210
+ `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
211
+ `Be concise and concrete. No preamble.`;
212
+ const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
213
+ return { text: text || "(couldn't reach the model)", sub: `${open.length} open items · todo`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${open.length} open items` };
214
+ }
215
+ const label = cmd === "standup" ? "standup" : "week";
216
+ const days = cmd === "standup" ? 2 : since;
217
+ const who = me();
218
+ // Discover the Map's repo set, then keep only those with commits in the window.
219
+ const all = discoveredRepos();
220
+ const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
221
+ const mail = sentMail(days); // [] unless email is switched on
222
+ const waiting = label === "standup" ? waitingOn(WAITING_HOURS) : [], line = waitingLine(waiting);
223
+ 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 };
225
+ let cs = commits(repos, `${days} days ago`, !everyone);
226
+ if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
227
+ 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 };
229
+
230
+ const system =
231
+ `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
232
+ `Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
233
+ `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
234
+ (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. ` : "") +
235
+ `${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.` +
237
+ (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
+ const prompt =
239
+ `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
240
+ `Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
241
+ (mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
242
+ (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
243
+ (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
+ `Write the ${label === "standup" ? "standup" : "update"}.`;
245
+
246
+ const text = await write(system, prompt);
247
+ const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
248
+ 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
+ }
250
+
251
+ export { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce };