symbiot 0.58.2 → 0.59.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
@@ -25,7 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, statSyn
25
25
  import { execFile } from "node:child_process";
26
26
  import { randomBytes } from "node:crypto";
27
27
  import { VERSION, CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
28
- import { readPage, siteUrl, isTrusted, signIn } from "./headless.mjs";
28
+ import { readPage, readLinkedIn, siteUrl, isTrusted, signIn } from "./headless.mjs";
29
29
  import { loadScreens } from "./screens.mjs";
30
30
  import { desktopNotify } from "./desktop.mjs";
31
31
  import { resolveProvider, write } from "./ai.mjs";
@@ -39,6 +39,7 @@ const WATCH_FILE = join(CONFIG_DIR, "watch.json");
39
39
  const EVERY = [5, 15, 30, 60]; // minutes between reads
40
40
  // 40 watches: every standard Link (links.mjs) plus a company's own and yours; 1000 news: a
41
41
  // busy inbox can't push the rest of the week out before Week is written
42
+ const BRIEF_HOURS = 72; // the longest window the Dashboard shows a card's items for
42
43
  const MAX_WATCHES = 40, MAX_SEEN = 1000, MAX_NEWS = 1000, MAX_PER_READ = 25, MAX_BRIEFS = 50;
43
44
 
44
45
  function loadWatch() {
@@ -191,15 +192,51 @@ function setBrief(on) { const cfg = loadConfig(); if (on) cfg.watchBrief = true;
191
192
  // A chat list (chat: true) says in brackets who each chat's last message is from.
192
193
  const CHAT_RULE = `This is a chat list (WhatsApp). A chat's line is its name and the start of its last message, and doesn't say who wrote that message: the brackets do. ` +
193
194
  `Only a chat with unread messages from them can need the user. One whose last message is the user's own, or has nothing unread, never needs a reply: don't say it does, and never take its message as the contact's. Say "nothing needs you" when none is unread. `;
194
- async function briefOf(news, name, { chat = false } = {}) {
195
+ // LinkedIn (social: true): a message is the likeliest to need a reply, and the brief
196
+ // once skipped one listed under two notifications (2026-10-09). So every listed item
197
+ // gets a mention, messages first and on their own line.
198
+ const SOCIAL_RULE = `This is LinkedIn: messages and notifications. Mention every item listed, none left out (group alike notifications in one line if you need to). ` +
199
+ `Messages come first, on a line of their own starting "Messages:", naming who wrote and what they said when that's shown; an unread message usually wants a reply. Then the notifications, by who did what. `;
200
+ async function briefOf(news, name, { chat = false, social = false } = {}) {
195
201
  if (!resolveProvider()) return "";
196
- const system = `You triage what just arrived on a page someone watches: their email inbox, their chats, or their GitHub notifications. ` +
197
- `In at most 3 short lines of plain text, first what needs them (and why, in a few words), then what can wait. ` + (chat ? CHAT_RULE : "") +
202
+ const system = `You triage what just arrived on a page someone watches: their email inbox, their chats, their LinkedIn or their GitHub notifications. ` +
203
+ `In at most ${social ? 4 : 3} short lines of plain text, first what needs them (and why, in a few words), then what can wait. ` + (chat ? CHAT_RULE : "") + (social ? SOCIAL_RULE : "") +
198
204
  `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.`;
199
- const text = await write(system, `New on ${name}:\n${news.map((n) => "- " + (chat ? withFrom(n) : n.text)).join("\n")}\n\nWhat needs me, and what can wait?`);
205
+ const line = (n) => (chat ? withFrom(n) : social && n.li ? `[${n.li === "message" ? "message" : n.type || "notification"}] ${n.text}` : social && /^Messaging\b/i.test(n.text) ? `[message] ${n.text}` : n.text);
206
+ const text = await write(system, `New on ${name}:\n${news.map((n) => "- " + line(n)).join("\n")}\n\nWhat needs me, and what can wait?`);
200
207
  return text && !/^\(?couldn't reach the model/i.test(text) ? text.trim().slice(0, 600) : "";
201
208
  }
202
209
 
210
+ // ---- LinkedIn: who and what, not only how many ----------------------------------
211
+ // Its top bar's badges ("Messaging, 1 new notification", "Notifications, 2 new
212
+ // notifications") were all Watch read there. A badge that's new is read out from
213
+ // LinkedIn's own lists (headless.mjs readLinkedIn): each unread conversation as
214
+ // "Message from <who>: <its last line>", each unread card in its own words, with
215
+ // who (actor) and what kind (type). None marked unread: the badge's count, newest
216
+ // first. A list that can't be read (the browser busy, signed out) keeps the badge.
217
+ const LI_BADGE = /^(Messaging|Notifications)\b[,:]?\s+(\d+)\s+new\b/i, LI_MSG = "https://www.linkedin.com/messaging/";
218
+ const LI_TYPES = [[/\bcommented\b/i, "comment"], [/\brepl(ied|y)\b/i, "reply"], [/\bmentioned\b/i, "mention"], [/\b(reacted|liked|celebrated|loves?|supports?|finds? .{0,40}(insightful|funny|curious))\b/i, "reaction"],
219
+ [/\b(reposted|shared)\b/i, "repost"], [/\b(viewed your profile|profile views?|appeared in \d+ searches)\b/i, "profile view"], [/\b(invit\w*|connection request|accepted your)\b/i, "invitation"],
220
+ [/\bendorsed\b/i, "endorsement"], [/\b(followed you|started following)\b/i, "follow"], [/\bjobs?\b/i, "job"], [/\b(birthday|work anniversary|new position|started a new)\b/i, "milestone"], [/\b(posted|published)\b/i, "post"]];
221
+ function liType(text) { for (const [re, t] of LI_TYPES) if (re.test(String(text || ""))) return t; return "notification"; }
222
+ const liPick = (list, n) => { const un = list.filter((x) => x.unread); return un.length ? un : list.slice(0, Math.max(1, n || 1)); };
223
+ // fresh (what's new this read) with its badges read out; look(kind) reads a list
224
+ async function linkedinDetail(fresh, look = readLinkedIn) {
225
+ const out = [];
226
+ for (const it of fresh) {
227
+ const m = String(it.text || "").match(LI_BADGE); if (!m) { out.push(it); continue; }
228
+ const kind = /^messaging/i.test(m[1]) ? "messages" : "notifications";
229
+ let r = null; try { r = await look(kind); } catch {}
230
+ const got = r && Array.isArray(r.items) && r.items.length ? liPick(r.items, Number(m[2])) : [];
231
+ if (!got.length) { out.push(it); continue; }
232
+ for (const g of got) out.push(kind === "messages"
233
+ ? { text: `Message from ${g.name}${g.preview ? ": " + g.preview : ""}`.slice(0, 300), href: LI_MSG, li: "message", sender: String(g.name).slice(0, 80) }
234
+ : { text: String(g.text).slice(0, 300), href: g.href || it.href, li: "notification", type: liType(g.text), ...(g.actor ? { actor: String(g.actor).slice(0, 80) } : {}) });
235
+ }
236
+ return out;
237
+ }
238
+ const liFields = (it) => (it.li ? { li: it.li, ...(it.sender ? { sender: it.sender } : {}), ...(it.actor ? { actor: it.actor } : {}), ...(it.type ? { type: it.type } : {}) } : {});
239
+
203
240
  // ---- watches -------------------------------------------------------------------
204
241
  // A watch whose last read found the sign-in page: it reads nothing until you sign in again.
205
242
  const SIGNED_OUT = "Signed out of ";
@@ -669,7 +706,7 @@ async function openChat(id, { open = signIn } = {}) {
669
706
  // briefOf (the tests pass their own). Gives the watch, plus `new` (what was new)
670
707
  // and `brief`, or `learned` (the first read), or `busy` when the hidden browser
671
708
  // is in use (nothing changes: it's read later).
672
- async function checkWatch(id, { read = readPage, github = readGitHub, notify = desktopNotify, brief = briefOf } = {}) {
709
+ async function checkWatch(id, { read = readPage, github = readGitHub, notify = desktopNotify, brief = briefOf, linkedin = readLinkedIn, now = null } = {}) {
673
710
  const w0 = loadWatch().watches.find((x) => x.id === id); if (!w0) return { error: `No watch ${id}.` };
674
711
  const page = (isGitHubInbox(w0.url) && await github()) || (await read(w0.url)) || { error: "Nothing came back from the page." };
675
712
  if (page.busy) return { ...view(w0), busy: true };
@@ -690,7 +727,13 @@ async function checkWatch(id, { read = readPage, github = readGitHub, notify = d
690
727
  delete w.error;
691
728
  // read another way than last time (gh, or the page while gh is out): the same
692
729
  // things look different, so learn them again rather than announce them all
693
- const first = !w.checked || (w.via || "") !== (page.via || ""), { fresh, keys } = newItems(w.seen, items);
730
+ const first = !w.checked || (w.via || "") !== (page.via || ""), seen0 = w.seen;
731
+ let { fresh, keys } = newItems(seen0, items);
732
+ // LinkedIn: a new badge, read out into who and what (and those remembered too, so a message still unread isn't news twice)
733
+ if (!first && isSocial(w.url) && fresh.some((it) => LI_BADGE.test(String(it.text || "")))) {
734
+ const told = newItems(seen0, await linkedinDetail(fresh, linkedin));
735
+ fresh = told.fresh; keys = [...keys, ...told.keys.filter((k) => !keys.includes(k))];
736
+ }
694
737
  if (page.via) w.via = page.via; else delete w.via;
695
738
  w.seen = remember(w.seen, keys); w.checked = w.last;
696
739
  const chat = isChat(w.url);
@@ -703,7 +746,7 @@ async function checkWatch(id, { read = readPage, github = readGitHub, notify = d
703
746
  }
704
747
  if (chat) recheckChats(d.news.filter((n) => n.watch === w.id), items);
705
748
  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 } : {}),
706
- ...(chat ? { from: fromOf(it), ...(it.unread ? { unread: it.unread } : {}) } : {}), ...(readMark && !it.unread ? { read: true } : {}) }));
749
+ ...(chat ? { from: fromOf(it), ...(it.unread ? { unread: it.unread } : {}) } : {}), ...(readMark && !it.unread ? { read: true } : {}), ...liFields(it) }));
707
750
  d.news = [...news, ...d.news].slice(0, MAX_NEWS);
708
751
  saveWatch(d);
709
752
  if (first) return { ...view(w), learned: keys.length };
@@ -711,7 +754,11 @@ async function checkWatch(id, { read = readPage, github = readGitHub, notify = d
711
754
  const needy = news.filter((n) => needsYou(n, w));
712
755
  let said = "";
713
756
  if (needy.length && briefOn()) {
714
- said = await brief(chat ? news : needy, w.name, { chat }).catch(() => ""); // a chat's brief says who each is from; mail you've read isn't news
757
+ // the brief covers what the card lists (BRIEF_HOURS), not only this read's: the card
758
+ // showed a message from two days before, and the brief left it out (2026-10-09)
759
+ const social = isSocial(w.url), since = (now || w.last) - BRIEF_HOURS * 3600000;
760
+ const listed = social ? d.news.filter((n) => n.watch === w.id && n.ts >= since && unseen(n, w) && needsYou(n, w)).slice(0, MAX_PER_READ) : null;
761
+ said = await brief(chat ? news : listed || needy, w.name, { chat, social }).catch(() => ""); // a chat's brief says who each is from; mail you've read isn't news
715
762
  if (said) { const d2 = loadWatch(); d2.briefs = [{ id: randomBytes(4).toString("hex"), watch: w.id, name: w.name, ts: w.last, count: needy.length, text: said }, ...d2.briefs].slice(0, MAX_BRIEFS); saveWatch(d2); }
716
763
  }
717
764
  if (needy.length) notify(...newsNotice(needy, w.name, said));
@@ -734,4 +781,4 @@ function startWatches(opts = {}) {
734
781
  return () => { clearTimeout(first); clearInterval(every); };
735
782
  }
736
783
 
737
- export { replyOf, draftCard, draftCards, draftAnswer, isDraftDir, sendBrief, LINK_ASK, WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, fromOf, chatName, recheckChats, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat, talkOf, isSignedOut, isMail, isChat, isSocial, draftsUrl, draftBrief, chatBrief, socialBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };
784
+ export { linkedinDetail, liType, replyOf, draftCard, draftCards, draftAnswer, isDraftDir, sendBrief, LINK_ASK, WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, fromOf, chatName, recheckChats, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat, talkOf, isSignedOut, isMail, isChat, isSocial, draftsUrl, draftBrief, chatBrief, socialBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };