symbiot 0.44.0 → 0.45.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/adapt.mjs ADDED
@@ -0,0 +1,222 @@
1
+ // symbiot — Adapt: Symbiot shapes itself to the person using it, with no
2
+ // settings. It measures how they work and how they talk; the browser side reads
3
+ // their colours from the system (ui.mjs). Every rule here is a known model, so
4
+ // what it does can be checked rather than taken on trust:
5
+ //
6
+ // - Frecency: each shape's use decays with a half-life (HALF_LIFE): score =
7
+ // score·2^(−Δt/h) + 1 per use. Recent counts most; nothing needs a history.
8
+ // - What comes next: a Markov chain over shape-to-shape moves, with a Dirichlet
9
+ // prior (Laplace smoothing, ALPHA) so a handful of moves can't swing it, and an
10
+ // hour-of-day profile smoothed over neighbouring hours (circular kernel).
11
+ // Combined as a product of experts into p, a probability per shape.
12
+ // - How predictable they are: Shannon entropy of p, normalised to 0..1. Low
13
+ // entropy (habits) lets the layout adapt strongly; high entropy (scattered
14
+ // use) keeps it uniform and stable. Few events in all: it barely adapts yet.
15
+ // - Size, Fitts's law: time to hit a target is a + b·log2(D/W + 1). Minimising
16
+ // the expected time Σ p·T over all shapes with the total area fixed gives
17
+ // area ∝ p (radius ∝ √p). The fixed total is conservation of mass: one
18
+ // droplet growing takes liquid from the others.
19
+ // - How many to show, Hick–Hyman: choice time grows with log2(n + 1), so the
20
+ // shapes that cover HICK_COVER of the probability show, the rest go under
21
+ // "more" (always reachable, and by talking).
22
+ // - Stability, hysteresis: a new layout replaces the shown one only when it
23
+ // saves at least HYSTERESIS of the expected Fitts time, and only when asked to
24
+ // commit (the app does that when it wakes from rest, never mid-gesture). A
25
+ // shape's angle is fixed once given, so spatial memory holds: adapting moves
26
+ // size and distance, not where things are.
27
+ // - How they work it: talk vs click vs keyboard vs touch, each a frecency score,
28
+ // decides the talk band's weight, the target size floor (a finger needs more
29
+ // than a pointer: 48 vs 44 px) and whether focus rings stay on.
30
+ // - How they talk, Communication Accommodation: a style profile from what they
31
+ // type (length, capitals, punctuation, emoji, questions vs instructions, the
32
+ // languages they mix in) becomes one instruction for every reply and brief:
33
+ // converge on their style, without copying their typos.
34
+ //
35
+ // Stored in ~/.config/symbiot/adapt.json, yours only.
36
+ import { join } from "node:path";
37
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
38
+ import { CONFIG_DIR } from "./core.mjs";
39
+
40
+ const ADAPT_FILE = join(CONFIG_DIR, "adapt.json");
41
+ const MIND_FILE = join(CONFIG_DIR, "mind.json");
42
+ const DAY = 86400000;
43
+ const HALF_LIFE = 3 * DAY;
44
+ const ALPHA = 1; // Dirichlet prior per outcome
45
+ const EXPERT_W = { use: 1, next: 0.7, hour: 0.5 }; // product-of-experts exponents
46
+ const HOUR_KERNEL = [0.25, 0.5, 0.25]; // weights for the hour before, the hour, the hour after
47
+ const MIN_EVENTS = 12; // under this many uses, adaptation fades in
48
+ const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
49
+ const HYSTERESIS = 0.15;
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"];
52
+
53
+ function loadAdapt(file = ADAPT_FILE) {
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 }; }
55
+ catch { return { shapes: {}, trans: {}, hours: {}, modes: {}, slots: {}, shown: null, events: 0 }; }
56
+ }
57
+ function saveAdapt(d, file = ADAPT_FILE) {
58
+ try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(file, JSON.stringify(d), { mode: 0o600 }); try { chmodSync(file, 0o600); } catch {} return true; } catch { return false; }
59
+ }
60
+
61
+ // ---- frecency ---------------------------------------------------------------------
62
+ const decayed = (rec, now) => (rec ? rec.s * Math.pow(2, -(now - rec.t) / HALF_LIFE) : 0);
63
+ const bump = (rec, now) => ({ s: decayed(rec, now) + 1, t: now, n: ((rec && rec.n) || 0) + 1 });
64
+
65
+ // One use of a shape: from where, how (click, talk, key or touch), when.
66
+ function recordUse(d, shape, { from = "", via = "click", now = Date.now() } = {}) {
67
+ if (!shape) return d;
68
+ d.shapes[shape] = bump(d.shapes[shape], now);
69
+ if (from && from !== shape) { const row = d.trans[from] || (d.trans[from] = {}); row[shape] = (row[shape] || 0) + 1; }
70
+ const h = new Date(now).getHours(), hs = d.hours[shape] || (d.hours[shape] = new Array(24).fill(0)); hs[h]++;
71
+ if (["click", "talk", "key", "touch"].includes(via)) d.modes[via] = bump(d.modes[via], now);
72
+ if (!(shape in d.slots)) d.slots[shape] = Object.keys(d.slots).length;
73
+ d.events = (d.events || 0) + 1;
74
+ return d;
75
+ }
76
+
77
+ // ---- prediction -------------------------------------------------------------------
78
+ const normalise = (xs) => { const s = xs.reduce((a, b) => a + b, 0); return s > 0 ? xs.map((x) => x / s) : xs.map(() => 1 / xs.length); };
79
+ function predict(d, ids = SHAPES, { from = "", hour = new Date().getHours(), now = Date.now() } = {}) {
80
+ const K = ids.length;
81
+ const pUse = normalise(ids.map((id) => decayed(d.shapes[id], now) + ALPHA));
82
+ const row = (from && d.trans[from]) || {}, N = ids.reduce((s, id) => s + (row[id] || 0), 0);
83
+ const pNext = ids.map((id) => ((row[id] || 0) + ALPHA) / (N + ALPHA * K));
84
+ const pHour = normalise(ids.map((id) => { const hs = d.hours[id] || []; return HOUR_KERNEL.reduce((s, w, k) => s + w * (hs[(hour + k - 1 + 24) % 24] || 0), 0) + ALPHA; }));
85
+ const log = ids.map((_, i) => EXPERT_W.use * Math.log(pUse[i]) + EXPERT_W.next * Math.log(pNext[i]) + EXPERT_W.hour * Math.log(pHour[i]));
86
+ const m = Math.max(...log);
87
+ return normalise(log.map((x) => Math.exp(x - m)));
88
+ }
89
+ // Shannon entropy of p over its K outcomes, normalised to 0 (certain) .. 1 (uniform).
90
+ function entropy(p) { const K = p.length; if (K < 2) return 0; return -p.reduce((s, x) => s + (x > 0 ? x * Math.log(x) : 0), 0) / Math.log(K); }
91
+ // How strongly to adapt, 0..1: predictable use and enough of it.
92
+ function adaptivity(d, p) { return (1 - entropy(p)) * Math.min(1, (d.events || 0) / MIN_EVENTS); }
93
+
94
+ // ---- layout: Fitts, Hick, mass conservation ---------------------------------------
95
+ const fittsTime = (D, W) => FITTS.a + FITTS.b * Math.log2(D / Math.max(W, 1) + 1);
96
+ // Radii from probability under a fixed total area (area ∝ p minimises Σ p·log(D/W)),
97
+ // clamped to [minR, maxR] with the leftover area shared again among the rest
98
+ // (water-filling), so the total stays the budget.
99
+ function allocate(p, { budget, minR, maxR }) {
100
+ let r = p.map(() => 0), free = p.map((_, i) => i), area = budget;
101
+ for (let pass = 0; pass < p.length && free.length; pass++) {
102
+ const ps = free.reduce((s, i) => s + p[i], 0) || 1, next = [];
103
+ for (const i of free) {
104
+ const ri = Math.sqrt((area * p[i] / ps) / Math.PI);
105
+ if (ri < minR) r[i] = minR;
106
+ else if (ri > maxR) r[i] = maxR;
107
+ else { r[i] = ri; next.push(i); }
108
+ }
109
+ if (next.length === free.length) break; // nothing clamped: done
110
+ area = Math.max(0, budget - p.reduce((s, _, i) => s + (next.includes(i) ? 0 : Math.PI * r[i] * r[i]), 0));
111
+ free = next;
112
+ }
113
+ return r;
114
+ }
115
+ // The shapes to show (Hick): fewest that cover HICK_COVER, within [HICK_MIN, HICK_MAX].
116
+ function hick(ids, p) {
117
+ const order = ids.map((id, i) => ({ id, p: p[i] })).sort((a, b) => b.p - a.p);
118
+ let cum = 0; const shown = [];
119
+ for (const o of order) { if (shown.length >= HICK_MAX) break; if (shown.length >= HICK_MIN && cum >= HICK_COVER) break; shown.push(o.id); cum += o.p; }
120
+ return { shown, more: order.filter((o) => !shown.includes(o.id)).map((o) => o.id) };
121
+ }
122
+ // Expected Fitts time of a layout under p: [{ id, d, r }] (d, r in px).
123
+ function expectedTime(items, pOf) { return items.reduce((s, it) => s + (pOf[it.id] || 0) * fittsTime(it.d, 2 * it.r), 0); }
124
+
125
+ // The layout for these shapes: who shows, how big (radius, px) and how far from
126
+ // the core (px), and its fixed angle. Mixed toward uniform by (1 − adaptivity),
127
+ // then held by hysteresis unless the saving is worth it and commit is set.
128
+ function layoutFor(d, ids = SHAPES, { from = "", now = Date.now(), hour, budget = 7 * Math.PI * 42 * 42, minR = 26, maxR = 64, near = 250, far = 400, commit = false, touch = false } = {}) {
129
+ const pRaw = predict(d, ids, { from, now, hour: hour ?? new Date(now).getHours() });
130
+ const a = adaptivity(d, pRaw), p = pRaw.map((x) => a * x + (1 - a) / ids.length);
131
+ const pOf = Object.fromEntries(ids.map((id, i) => [id, p[i]]));
132
+ const { shown, more } = hick(ids, p);
133
+ const floor = touch ? Math.max(minR, 34) : minR;
134
+ const radii = allocate(shown.map((id) => pOf[id]), { budget, minR: floor, maxR });
135
+ const pmax = Math.max(...shown.map((id) => pOf[id])), pmin = Math.min(...shown.map((id) => pOf[id]));
136
+ for (const id of ids) if (!(id in d.slots)) d.slots[id] = Object.keys(d.slots).length;
137
+ const items = shown.map((id, i) => ({ id, p: pOf[id], r: radii[i], d: pmax > pmin ? far - (far - near) * (pOf[id] - pmin) / (pmax - pmin) : (near + far) / 2, angle: (d.slots[id] * 2.399963) % (2 * Math.PI) })); // golden angle: slots never collide
138
+ let use = { items, more, at: now, touch: !!touch };
139
+ const prev = d.shown && d.shown.items && d.shown.items.length ? d.shown : null;
140
+ if (prev) {
141
+ const sameSet = prev.items.map((x) => x.id).sort().join() === items.map((x) => x.id).sort().join();
142
+ const gain = (expectedTime(prev.items, pOf) - expectedTime(items, pOf)) / Math.max(expectedTime(prev.items, pOf), 1e-9);
143
+ // a new input method (a finger now, not a pointer) always re-lays out: its targets must fit it
144
+ if (!!prev.touch === !!touch && (!commit || (sameSet && gain < HYSTERESIS))) use = prev; // not worth moving things under your hand
145
+ }
146
+ if (commit || !prev) d.shown = use;
147
+ return { ...use, p: pOf, adaptivity: a, entropy: entropy(pRaw), changed: use !== prev };
148
+ }
149
+
150
+ // How they work it: shares of talk / click / key / touch, from frecency.
151
+ function modes(d, now = Date.now()) {
152
+ const ks = ["talk", "click", "key", "touch"], v = ks.map((k) => decayed(d.modes[k], now)), s = v.reduce((a, b) => a + b, 0);
153
+ const share = Object.fromEntries(ks.map((k, i) => [k, s ? v[i] / s : 0]));
154
+ return { ...share, talkWeight: Math.min(1, 0.35 + share.talk), touch: share.touch > 0.3, keyboard: share.key > 0.25 };
155
+ }
156
+
157
+ // ---- how they talk -----------------------------------------------------------------
158
+ // Words that are distinctly Afrikaans (none that are also everyday English:
159
+ // not "is", "met", "van", "op", "more"). A message counts as Afrikaans with two.
160
+ const AFRIKAANS = new Set("die en ek jy julle ons nie baie dankie lekker uit asseblief wat hoe nou ja nee maar ook vir sal kan moet hier daar goed mooi totsiens aand dit hulle weet gaan kom".split(" "));
161
+ // A request, however it's put: a bare verb, "i want / i need / can you / it
162
+ // should / make sure …", with a greeting or "now / ok / so" in front.
163
+ const IMPERATIVE = /^(?:(?:hey|hi|ok|okay|so|and|now|then|right|please)[,!.]?\s+)*(?:(?:i|we)\s+(?:want|need|would like|'d like)|can you|could you|would you|it should|make sure|let's|lets)?\s*(?:please\s+)?(add|make|fix|build|do|go|check|look|show|tell|send|close|open|set|put|take|stop|start|run|push|get|give|find|write|change|update|remove|delete|merge|bump|move|use|try|create|let|keep|raise|lower|link|ship|publish|approve|test|clean|rename|drop|turn|switch|copy|paste|install|connect|sign|watch|read|reply|draft|answer|have|be|see|know)?\b/i;
164
+ const isRequest = (t) => { const m = t.match(IMPERATIVE); return !!(m && (m[1] || /^(?:(?:hey|hi|ok|okay|so|and|now|then|right|please)[,!.]?\s+)*(?:(?:i|we)\s+(?:want|need|would like|'d like)|can you|could you|would you|it should|make sure)/i.test(t))); };
165
+ const EMOJI = /\p{Extended_Pictographic}/u;
166
+ const median = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
167
+ // A style profile from what they've typed; null with too little to go on.
168
+ function styleOf(texts) {
169
+ const ms = (texts || []).map((t) => String(t || "").trim()).filter((t) => t.length > 1).slice(-120);
170
+ if (ms.length < 3) return null;
171
+ const share = (f) => ms.filter(f).length / ms.length;
172
+ const words = ms.map((t) => t.split(/\s+/).length);
173
+ const afrikaans = (t) => (t.toLowerCase().match(/[a-z']+/g) || []).filter((w) => AFRIKAANS.has(w)).length >= 2;
174
+ return {
175
+ samples: ms.length,
176
+ words: median(words),
177
+ lowercase: share((t) => /^[a-z]/.test(t)),
178
+ endsPunct: share((t) => /[.!?]$/.test(t)),
179
+ emoji: share((t) => EMOJI.test(t)),
180
+ questions: share((t) => /\?\s*$/.test(t)),
181
+ instructions: share(isRequest),
182
+ emphasis: share((t) => /[A-Z]{3,}/.test(t) || /!{2,}/.test(t)),
183
+ afrikaans: share(afrikaans),
184
+ };
185
+ }
186
+ // One instruction from the profile: converge on their style (accommodation),
187
+ // never copy their typos. "" when there isn't enough to go on.
188
+ function styleLine(st) {
189
+ if (!st) return "";
190
+ const parts = [];
191
+ const target = Math.round(Math.min(120, Math.max(20, st.words * 2.5)));
192
+ parts.push(st.words <= 12 ? `They write short (about ${Math.round(st.words)} words a message): keep replies brief, about ${target} words, the point first, no pleasantries` : `They write at length (about ${Math.round(st.words)} words): you can explain, up to about ${target} words`);
193
+ if (st.lowercase > 0.6 && st.endsPunct < 0.5) parts.push("their tone is casual (lowercase, little punctuation): plain and relaxed, not formal");
194
+ if (st.instructions > 0.4) parts.push("they mostly give instructions: act and say what you did, rather than discuss");
195
+ else if (st.questions > 0.4) parts.push("they mostly ask: answer directly");
196
+ if (st.emoji < 0.05) parts.push("no emoji"); else if (st.emoji > 0.2) parts.push("an emoji is fine where it fits");
197
+ if (st.emphasis > 0.08) parts.push("when they write in capitals they mean it: take it as the priority");
198
+ if (st.afrikaans > 0.08) parts.push("they mix in Afrikaans: understand it, keep their Afrikaans words as they are, and reply in Afrikaans only when they write a whole message in it");
199
+ return "Match how they talk: " + parts.join("; ") + ". Don't copy their typos.";
200
+ }
201
+ // The style line from everything typed to Symbiot's chats (mind.json's log).
202
+ function userStyleLine(file = MIND_FILE) {
203
+ try { const d = JSON.parse(readFileSync(file, "utf8")); return styleLine(styleOf((d.log || []).filter((l) => l.role === "user").map((l) => l.text))); } catch { return ""; }
204
+ }
205
+
206
+ // ---- the app's view ------------------------------------------------------------------
207
+ function adaptState({ from = "", commit = false, touch, now = Date.now() } = {}) {
208
+ const d = loadAdapt(), m = modes(d, now);
209
+ const layout = layoutFor(d, SHAPES, { from, now, commit, touch: touch ?? m.touch });
210
+ if (commit || layout.changed) saveAdapt(d);
211
+ let style = null; try { const md = JSON.parse(readFileSync(MIND_FILE, "utf8")); style = styleOf((md.log || []).filter((l) => l.role === "user").map((l) => l.text)); } catch {}
212
+ return { layout, modes: m, style, styleLine: styleLine(style), events: d.events || 0 };
213
+ }
214
+ function noteUse(body = {}) {
215
+ const shape = String(body.shape || ""), via = String(body.via || "click"), from = String(body.from || "");
216
+ if (!SHAPES.includes(shape)) return { error: "unknown shape" };
217
+ const d = recordUse(loadAdapt(), shape, { from: SHAPES.includes(from) ? from : "", via });
218
+ saveAdapt(d);
219
+ return { ok: true, events: d.events };
220
+ }
221
+
222
+ export { ADAPT_FILE, SHAPES, HALF_LIFE, HYSTERESIS, decayed, bump, recordUse, predict, entropy, adaptivity, fittsTime, allocate, hick, expectedTime, layoutFor, modes, styleOf, styleLine, userStyleLine, adaptState, noteUse, loadAdapt, saveAdapt };
package/agents.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { spawn } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
8
9
  import { randomBytes, createHash } from "node:crypto";
9
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
@@ -90,8 +91,34 @@ function withConnectors(tmpl, dir, file) {
90
91
  if (!isClaudeCmd(tmpl)) return tmpl;
91
92
  return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
92
93
  }
93
- // For Settings → Handoff: the connectors, and whether this command's runs get them.
94
- function connectorsInfo(file) { return { claude: isClaudeCmd(handoffCmd()), list: claudeConnectors("", file).map(({ name, ready }) => ({ name, ready })) }; }
94
+ // Linking a site in Symbiot (Links: Gmail, Drive…) signs Symbiot's own browser in;
95
+ // it doesn't give Claude's runs that site's tools. Those come from Claude's own
96
+ // connector for it (claude.ai → Settings → Connectors), passed through above. So
97
+ // Drive, linked in both, reached runs, and Gmail, linked only in Symbiot, didn't.
98
+ // The sites linked in Symbiot (config.linked) that Claude has connectors for, each
99
+ // with the one it has (connector: its name, or "") and whether it's ready.
100
+ const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
101
+ 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] };
102
+ function linkedConnectors(conns = claudeConnectors(), l = loadConfig().linked) {
103
+ if (!l || typeof l !== "object" || Array.isArray(l)) return [];
104
+ return Object.keys(l).filter((id) => LINK_CONNECTOR[id]).map((id) => {
105
+ const [name, re] = LINK_CONNECTOR[id], c = conns.find((x) => re.test(x.name));
106
+ return { id, name, connector: c ? c.name : "", ready: !!c && c.ready };
107
+ });
108
+ }
109
+ // For a run's brief: what its connectors are, and what's linked in Symbiot that
110
+ // isn't one (no tools for it in this run). "" when nothing's linked to either.
111
+ function connectorsLine(tmpl = handoffCmd(), file, linked) {
112
+ if (!isClaudeCmd(tmpl)) return "";
113
+ const conns = claudeConnectors("", file), ready = conns.filter((c) => c.ready), off = linkedConnectors(conns, linked).filter((x) => !x.ready);
114
+ 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];
115
+ const has = ready.length ? `this run can use ${ready.map((c) => `${nm(c.name)} (\`${c.rule}__…\` tools)`).join(", ")}.` : "";
116
+ 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.` : "";
117
+ return (has + not).trim();
118
+ }
119
+ // For Settings → Handoff: the connectors, whether this command's runs get them, and
120
+ // the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
121
+ function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
95
122
  const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
96
123
  // One agent per folder: two identical runs once started on the same repo 6s
97
124
  // apart and raced each other. The registry catches a second click in this
@@ -112,7 +139,7 @@ function runHandoff(repoPath, { force = false } = {}) {
112
139
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
113
140
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
114
141
  tmpl = withConnectors(tmpl, repoPath);
115
- if (!force) { const w = waitingFor(repoPath); if (w) return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; }
142
+ if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
116
143
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
117
144
  try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
118
145
  const lock = join(repoPath, ".symbiot", LOCK);
@@ -172,6 +199,23 @@ function earlierRuns() {
172
199
  // waits for Start it now. Start it anyway (force) always goes ahead.
173
200
  const WAITING = "waiting.json";
174
201
  const yourStep = (a) => /👤/.test(String(a || ""));
202
+ // An answer in the user's own words that says to wait is their step too: "don't
203
+ // start another run until it's in", "hold off until I've added it", "not yet".
204
+ // It waits on the files the question's 👤 options named (the `.env` that "it"
205
+ // goes in), since the answer itself rarely names one. "That can wait" isn't one.
206
+ const HOLD = [
207
+ /\b(?:don'?t|do not|never)\s+(?:start|run|send|launch|kick off|begin)\b/i,
208
+ /\b(?:another|next|new|a) (?:run|agent)\b[^.]{0,60}\b(?:until|till|unless|before)\b/i,
209
+ /\b(?:wait|hold(?: off| on| back)?|stop)\b[^.]{0,40}\b(?:until|till|for me to|before)\b/i,
210
+ /^\s*not yet\b/i,
211
+ ];
212
+ const holdAnswer = (a) => !yourStep(a) && HOLD.some((re) => re.test(String(a || "")));
213
+ // Asked for a run while one waits on a step of yours (a Send, a handover's
214
+ // result): it starts by itself once the step's done, as the note says.
215
+ function askedFor(path) {
216
+ const f = join(path, ".symbiot", WAITING);
217
+ try { const w = JSON.parse(readFileSync(f, "utf8")); if (w && !w.rerun) writeFileSync(f, JSON.stringify({ ...w, rerun: true })); } catch {}
218
+ }
175
219
  // A step in Settings → Handoff: the note names the command as what it waits on.
176
220
  const settingsStep = (s) => /\bsettings\b|\ballow\b|\bgrant|connector/i.test(String(s || ""));
177
221
  // The command the folder's next run would get, connectors and all (runHandoff).
@@ -495,12 +539,29 @@ function suggestionTarget(s) {
495
539
  // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each with
496
540
  // the repo whose tasks it goes to (this one unless it names another) and marked
497
541
  // if it's already on that repo's task list, in these words or nearly (sameTask).
542
+ // Ideas the user turned down (Skip) stay in .symbiot/SKIPPED.md, a file like
543
+ // ANSWERS.md, so they leave the list and the brief tells the next run not to
544
+ // suggest them again.
545
+ const SKIPPED = "SKIPPED.md";
546
+ const skippedIdeas = (path) => [...readSymbiot(path, SKIPPED).matchAll(/^-\s+(.+)$/gm)].map((m) => m[1].trim());
547
+ function skipIdea(path, text) {
548
+ path = String(path || ""); text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
549
+ if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
550
+ if (!text) return { error: "No idea to skip." };
551
+ const f = join(path, ".symbiot", SKIPPED);
552
+ if (!skippedIdeas(path).some((s) => sameTask(s, text))) {
553
+ try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, (existsSync(f) ? readFileSync(f, "utf8").replace(/\n*$/, "\n") : "# Ideas the user turned down\nDon't suggest these again.\n\n") + `- ${text}\n`); }
554
+ catch (e) { return { error: String((e && e.message) || e) }; }
555
+ }
556
+ return { ok: true };
557
+ }
498
558
  function agentQuestions(path, repo) {
499
559
  const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
500
560
  const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
501
561
  const open = p.questions.filter((x) => !done.has(qKey(x.q)));
502
- const tasks = p.suggestions.length ? loadTasks() : [];
503
- const ideas = p.suggestions.map(suggestionTarget).map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
562
+ const tasks = p.suggestions.length ? loadTasks() : [], skipped = p.suggestions.length ? skippedIdeas(path) : [];
563
+ const ideas = p.suggestions.map(suggestionTarget).filter(({ text }) => !skipped.some((s) => sameTask(s, text)))
564
+ .map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
504
565
  return {
505
566
  questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
506
567
  suggestions: ideas, ideasShown: IDEAS_SHOWN,
@@ -527,9 +588,14 @@ function answerQuestions(path, answers, opts = {}) {
527
588
  writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
528
589
  } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
529
590
  const out = { ok: true, saved: rows.length };
530
- const yours = rows.filter((x) => yourStep(x.a)).map((x) => stepText(x.a)).filter(Boolean);
591
+ // a "wait until…" answer: the step is the question's 👤 option (what "it" is), and its files are waited on
592
+ const asked = new Map(parseQuestions(readSymbiot(path, "QUESTIONS.md")).questions.map((x) => [qKey(x.q), x]));
593
+ const youOpts = (q) => ((asked.get(qKey(q)) || {}).options || []).filter(yourStep).map((o) => o.split("🤖")[0]);
594
+ const steps = rows.filter((x) => yourStep(x.a) || holdAnswer(x.a)).map((x) => yourStep(x.a) ? { step: stepText(x.a), named: x.a.split("🤖")[0] }
595
+ : { step: (youOpts(x.q).map(stepText)[0] || x.a).trim(), named: [x.a, ...youOpts(x.q)].join(" ") });
596
+ const yours = steps.map((s) => s.step).filter(Boolean);
531
597
  if (yours.length) {
532
- const step = yours.join(" "), files = namedFiles(rows.filter((x) => yourStep(x.a)).map((x) => x.a.split("🤖")[0]).join(" "), path);
598
+ const step = yours.join(" "), files = namedFiles(steps.map((s) => s.named).join(" "), path);
533
599
  try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), cmd: runCmd(path), at: Date.now(), rerun: !!opts.rerun })); } catch {}
534
600
  const on = waitOn(files.map((f) => f.name), settingsStep(step));
535
601
  out.yours = yours; if (files.length) out.waitFiles = files.map((f) => f.name);
@@ -557,4 +623,4 @@ function agentsList() {
557
623
  }).concat(earlierRuns());
558
624
  }
559
625
 
560
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
626
+ export { 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 };
package/core.mjs CHANGED
@@ -48,7 +48,7 @@ function sameTask(a, b) {
48
48
  const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
49
49
  if (x === y) return true;
50
50
  const [s, l] = x.length < y.length ? [x, y] : [y, x];
51
- return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b);
51
+ return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b) || sameAsk(a, b);
52
52
  }
53
53
  // Nearly the same task, two ways:
54
54
  // - nearly all the same words in the same order, 12+ words each ("…nothing
@@ -85,6 +85,23 @@ function nearTask(a, b) {
85
85
  for (; j < n; j++) if (y[j].name) ny = true;
86
86
  return !(nx && ny);
87
87
  }
88
+ // The same ask in other words: "Send Jono the two asks yourself: verify
89
+ // `X-Wa-Signature-256`…" and "Ask Jono to verify `X-Wa-Signature-256`… Also ask
90
+ // him…". Both name exactly the same things (Jono, X-Wa-Signature-256), and most
91
+ // of the shorter one's own words (not "the", "his", "once"; "asks" is "ask") are
92
+ // in the other. Checked against 263 real tasks (11,770 pairs): at 0.6 it found 7
93
+ // rewordings and nothing else; at 0.45 it took "Whole page for Gmail too" for
94
+ // "Map the whole page … (not for Gmail)".
95
+ const ASK = 0.6, ASK_WORDS = 8;
96
+ const STOP = new Set(("the and but for from with are was were been its this that these those then than once each his her him she they them their you your yourself can cant not does did have has had will would should could just also there here into onto out about before after when what which who whom how why all any some more most other only own same too very again").split(" "));
97
+ function askWords(s) { return new Set(wordsOf(s).map((x) => x.w).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.replace(/(ing|ed|es|s)$/, ""))); }
98
+ function sameAsk(a, b) {
99
+ const x = askWords(a), y = askWords(b); if (Math.min(x.size, y.size) < ASK_WORDS) return false;
100
+ const nx = new Set(wordsOf(a).filter((w) => w.name).map((w) => w.w)), ny = new Set(wordsOf(b).filter((w) => w.name).map((w) => w.w));
101
+ if (!nx.size || nx.size !== ny.size || [...nx].some((w) => !ny.has(w))) return false;
102
+ let both = 0; for (const w of x) if (y.has(w)) both++;
103
+ return both / Math.min(x.size, y.size) >= ASK;
104
+ }
88
105
  // One of each: near-duplicates collapse into the wording that says the most.
89
106
  function uniqueTasks(texts) {
90
107
  const out = [];
package/headless.mjs CHANGED
@@ -294,6 +294,14 @@ const collectJs = (whole, list = null) => `(() => { ${SCROLLER}
294
294
  const box = ${whole ? "{ x: Math.max(0, b.left + X), y: Math.max(0, b.top + Y), w: b.width, h: b.height }" : "{ x, y: y + down, w: x2 - x, h: y2 - y }"};
295
295
  const r = { label: full.slice(0, 80) || kind, kind, x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.w), h: Math.round(box.h), selector: reused ? '' : cssPath(e) };
296
296
  if (full.length > 80) r.text = full.slice(0, 400); // all of a long one (an inbox row), for Watch
297
+ // a chat list's row (WhatsApp's), for Watch: what only its icons say, not its text. Its
298
+ // unread badge ("2 unread messages"), and the ticks on a last message you sent
299
+ if (kind === 'row' || kind === 'menu item') {
300
+ const un = [...e.querySelectorAll('[aria-label]')].map((x) => clean(x.getAttribute('aria-label'))).find((s) => /\\bunread\\b/i.test(s));
301
+ if (un) r.unread = +(un.match(/(\\d+)\\s+unread/i) || [0, 1])[1] || 1;
302
+ if (e.querySelector('[data-icon^="msg-check"],[data-icon^="msg-dblcheck"],[data-icon^="msg-time"],[data-icon^="status-check"],[data-icon^="status-dblcheck"]') ||
303
+ [...e.querySelectorAll('[aria-label]')].some((x) => /^(read|delivered|sent|pending)$/i.test(clean(x.getAttribute('aria-label'))))) r.mine = true;
304
+ }
297
305
  if (e.href && /^https?:/.test(e.href)) r.href = String(e.href).slice(0, 500);
298
306
  out.push(r);
299
307
  }
package/home.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // symbiot — Home: what the liquid shows, from real data. Three kinds of droplet:
2
+ // - only you: what no agent can do (an Approve waiting for you; an agent's open
3
+ // question to you), always out front;
4
+ // - feeds: what's new on the sites you watch (watch.mjs), sized by how much;
5
+ // - lanes: work handed between agents (lanes.mjs), the latest few.
6
+ // The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
7
+ // how you use them; this is the live data around them. Home's talk goes to the
8
+ // same Symbiot as every chat (mind.mjs converse), told what home shows.
9
+ import { watchBoard } from "./watch.mjs";
10
+ import { pendingReview } from "./tasks.mjs";
11
+ import { agentsList } from "./agents.mjs";
12
+ import { lanesState } from "./lanes.mjs";
13
+ import { converse, actIn, actNow, taskIn } from "./mind.mjs";
14
+ import { repoPathMap } from "./scan.mjs";
15
+ import { resolveProvider } from "./ai.mjs";
16
+
17
+ const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
18
+ let cached = null;
19
+
20
+ // { you: [{ kind, id, title, sub, shape }], feeds: [{ id, title, sub, count, shape }],
21
+ // lanes: [{ from, to, text, status }], working: n, at }
22
+ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
23
+ if (!fresh && !deps.board && cached && now - cached.at < KEEP) return cached;
24
+ const board = deps.board || (() => { try { return watchBoard(24, now); } catch { return { cards: [] }; } });
25
+ const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
26
+ const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
27
+ const lanes = deps.lanes || (() => { try { return lanesState(); } catch { return { handoffs: [] }; } });
28
+ const you = [];
29
+ for (const r of pending()) {
30
+ if (!r.path || r.running) continue;
31
+ const n = (r.tasks || []).length, files = (r.files || []).length;
32
+ if (!n && !files) continue;
33
+ you.push({ kind: "approve", id: "approve:" + r.repo, title: `Approve ${r.repo}`, sub: `${n ? `${n} task${n > 1 ? "s" : ""} done` : "changes without a task"}${files ? ` · ${files} file${files > 1 ? "s" : ""}` : ""} · only you decide`, shape: "tasks" });
34
+ }
35
+ const list = agents(), seen = new Set();
36
+ for (const a of list) {
37
+ if (seen.has(a.path)) continue; seen.add(a.path);
38
+ const q = a.ask && a.ask.questions && a.ask.questions[0];
39
+ if (q) you.push({ kind: "ask", id: "ask:" + a.path, title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), shape: "agents" });
40
+ }
41
+ const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
42
+ const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status }));
43
+ const working = list.filter((a) => a.status === "running").length;
44
+ const out = { you: you.slice(0, 6), feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
45
+ if (!deps.board) cached = out;
46
+ return out;
47
+ }
48
+
49
+ // One line per thing home shows, for the talk's context.
50
+ function homeContext(h) {
51
+ return [
52
+ `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
53
+ `New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}`) : ["- nothing new"]),
54
+ `Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status})`) : ["- none"]),
55
+ ].join("\n");
56
+ }
57
+
58
+ // Home's talk: the same Symbiot, acting in the right lane (mind.mjs actIn/actNow).
59
+ async function homeAsk(question, { ask, now = Date.now(), state } = {}) {
60
+ question = String(question || "").trim().slice(0, 2000);
61
+ if (!question) return { error: "empty" };
62
+ if (!ask && !resolveProvider()) return { error: "not-connected" };
63
+ const h = state || homeState({ now });
64
+ let map = {}; try { map = repoPathMap(); } catch {}
65
+ const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
66
+ const r = await converse({ where: "Home", role, context: homeContext(h), question, map, now, ...(ask ? { ask } : {}),
67
+ act: {
68
+ agent: (req, known, repo) => (repo ? actIn(req, repo, { map, known, title: "Home" }) : actNow(req, { title: "Home", known })),
69
+ task: (text, repo) => taskIn(text, repo, { map }),
70
+ } });
71
+ return { answer: r.reply, ...(r.did ? { did: r.did } : {}) };
72
+ }
73
+
74
+ export { homeState, homeContext, homeAsk };
package/lanes.mjs CHANGED
@@ -19,7 +19,7 @@ import { join, basename } from "node:path";
19
19
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync } from "node:fs";
20
20
  import { createHash, randomBytes } from "node:crypto";
21
21
  import { CONFIG_DIR, loadTasks } from "./core.mjs";
22
- import { runHandoff, runningHandoff } from "./agents.mjs";
22
+ import { runHandoff, runningHandoff, waitingFor } from "./agents.mjs";
23
23
  import { addTask, pushTasks } from "./tasks.mjs";
24
24
  import { actNow } from "./mind.mjs";
25
25
  import { repoPathMap } from "./scan.mjs";
@@ -109,14 +109,16 @@ function outcome(e, { tasks = loadTasks(), running = runningHandoff } = {}) {
109
109
  }
110
110
  // Tell the agent that asked: the result as an answer in its ANSWERS.md, then
111
111
  // start it again to carry on (unless it's still running: it reads it next time).
112
- function report(e, o, { run = runHandoff, running = runningHandoff, now = Date.now() } = {}) {
112
+ // Not past a step of the user's it waits on (a key for .env, "don't start another
113
+ // run until it's in"): that run starts by itself once the step's done.
114
+ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = waitingFor, now = Date.now() } = {}) {
113
115
  const p = e.from.path, file = join(p, ".symbiot", "ANSWERS.md");
114
116
  try {
115
117
  mkdirSync(join(p, ".symbiot"), { recursive: true });
116
118
  const had = existsSync(file) ? readFileSync(file, "utf8") : "# Answers\nAnswers to your questions, and what other lanes did with what you handed over, newest last.\n";
117
119
  writeFileSync(file, `${had.replace(/\s*$/, "")}\n\n### Handed over to ${e.to.lane}: ${firstLine(e.text).slice(0, 120)}\n${o.text}\n_answered by symbiot (handover) ${new Date(now).toISOString().slice(0, 10)}_\n`);
118
120
  } catch { return false; }
119
- if (!running(p)) run(p, { force: true });
121
+ if (!running(p)) run(p, { force: !waiting(p) });
120
122
  return true;
121
123
  }
122
124