symbiot 0.44.1 → 0.46.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,9 +4,11 @@
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";
11
+ import { parseRun, lastRunText } from "./work.mjs";
10
12
 
11
13
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
12
14
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -90,8 +92,34 @@ function withConnectors(tmpl, dir, file) {
90
92
  if (!isClaudeCmd(tmpl)) return tmpl;
91
93
  return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
92
94
  }
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 })) }; }
95
+ // Linking a site in Symbiot (Links: Gmail, Drive…) signs Symbiot's own browser in;
96
+ // it doesn't give Claude's runs that site's tools. Those come from Claude's own
97
+ // connector for it (claude.ai → Settings → Connectors), passed through above. So
98
+ // Drive, linked in both, reached runs, and Gmail, linked only in Symbiot, didn't.
99
+ // The sites linked in Symbiot (config.linked) that Claude has connectors for, each
100
+ // with the one it has (connector: its name, or "") and whether it's ready.
101
+ const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
102
+ 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] };
103
+ function linkedConnectors(conns = claudeConnectors(), l = loadConfig().linked) {
104
+ if (!l || typeof l !== "object" || Array.isArray(l)) return [];
105
+ return Object.keys(l).filter((id) => LINK_CONNECTOR[id]).map((id) => {
106
+ const [name, re] = LINK_CONNECTOR[id], c = conns.find((x) => re.test(x.name));
107
+ return { id, name, connector: c ? c.name : "", ready: !!c && c.ready };
108
+ });
109
+ }
110
+ // For a run's brief: what its connectors are, and what's linked in Symbiot that
111
+ // isn't one (no tools for it in this run). "" when nothing's linked to either.
112
+ function connectorsLine(tmpl = handoffCmd(), file, linked) {
113
+ if (!isClaudeCmd(tmpl)) return "";
114
+ const conns = claudeConnectors("", file), ready = conns.filter((c) => c.ready), off = linkedConnectors(conns, linked).filter((x) => !x.ready);
115
+ 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];
116
+ const has = ready.length ? `this run can use ${ready.map((c) => `${nm(c.name)} (\`${c.rule}__…\` tools)`).join(", ")}.` : "";
117
+ 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.` : "";
118
+ return (has + not).trim();
119
+ }
120
+ // For Settings → Handoff: the connectors, whether this command's runs get them, and
121
+ // the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
122
+ function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
95
123
  const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
96
124
  // One agent per folder: two identical runs once started on the same repo 6s
97
125
  // apart and raced each other. The registry catches a second click in this
@@ -111,8 +139,8 @@ function runHandoff(repoPath, { force = false } = {}) {
111
139
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
112
140
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
113
141
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
114
- tmpl = withConnectors(tmpl, repoPath);
115
- if (!force) { const w = waitingFor(repoPath); if (w) return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; }
142
+ tmpl = withStream(withConnectors(tmpl, repoPath));
143
+ if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
116
144
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
117
145
  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
146
  const lock = join(repoPath, ".symbiot", LOCK);
@@ -153,8 +181,8 @@ function earlierRuns() {
153
181
  const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path);
154
182
  if (!busy && !ask.questions.length && !wait) continue;
155
183
  const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
156
- let tail = "", end = Date.now(); try { tail = readFileSync(log, "utf8").slice(-1200); if (!busy) end = statSync(log).mtimeMs; } catch {}
157
- out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
184
+ let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readFileSync(log, "utf8"))); if (!busy) end = statSync(log).mtimeMs; } catch {}
185
+ out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
158
186
  }
159
187
  return out;
160
188
  }
@@ -172,6 +200,23 @@ function earlierRuns() {
172
200
  // waits for Start it now. Start it anyway (force) always goes ahead.
173
201
  const WAITING = "waiting.json";
174
202
  const yourStep = (a) => /👤/.test(String(a || ""));
203
+ // An answer in the user's own words that says to wait is their step too: "don't
204
+ // start another run until it's in", "hold off until I've added it", "not yet".
205
+ // It waits on the files the question's 👤 options named (the `.env` that "it"
206
+ // goes in), since the answer itself rarely names one. "That can wait" isn't one.
207
+ const HOLD = [
208
+ /\b(?:don'?t|do not|never)\s+(?:start|run|send|launch|kick off|begin)\b/i,
209
+ /\b(?:another|next|new|a) (?:run|agent)\b[^.]{0,60}\b(?:until|till|unless|before)\b/i,
210
+ /\b(?:wait|hold(?: off| on| back)?|stop)\b[^.]{0,40}\b(?:until|till|for me to|before)\b/i,
211
+ /^\s*not yet\b/i,
212
+ ];
213
+ const holdAnswer = (a) => !yourStep(a) && HOLD.some((re) => re.test(String(a || "")));
214
+ // Asked for a run while one waits on a step of yours (a Send, a handover's
215
+ // result): it starts by itself once the step's done, as the note says.
216
+ function askedFor(path) {
217
+ const f = join(path, ".symbiot", WAITING);
218
+ try { const w = JSON.parse(readFileSync(f, "utf8")); if (w && !w.rerun) writeFileSync(f, JSON.stringify({ ...w, rerun: true })); } catch {}
219
+ }
175
220
  // A step in Settings → Handoff: the note names the command as what it waits on.
176
221
  const settingsStep = (s) => /\bsettings\b|\ballow\b|\bgrant|connector/i.test(String(s || ""));
177
222
  // The command the folder's next run would get, connectors and all (runHandoff).
@@ -544,9 +589,14 @@ function answerQuestions(path, answers, opts = {}) {
544
589
  writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
545
590
  } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
546
591
  const out = { ok: true, saved: rows.length };
547
- const yours = rows.filter((x) => yourStep(x.a)).map((x) => stepText(x.a)).filter(Boolean);
592
+ // a "wait until…" answer: the step is the question's 👤 option (what "it" is), and its files are waited on
593
+ const asked = new Map(parseQuestions(readSymbiot(path, "QUESTIONS.md")).questions.map((x) => [qKey(x.q), x]));
594
+ const youOpts = (q) => ((asked.get(qKey(q)) || {}).options || []).filter(yourStep).map((o) => o.split("🤖")[0]);
595
+ 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] }
596
+ : { step: (youOpts(x.q).map(stepText)[0] || x.a).trim(), named: [x.a, ...youOpts(x.q)].join(" ") });
597
+ const yours = steps.map((s) => s.step).filter(Boolean);
548
598
  if (yours.length) {
549
- const step = yours.join(" "), files = namedFiles(rows.filter((x) => yourStep(x.a)).map((x) => x.a.split("🤖")[0]).join(" "), path);
599
+ const step = yours.join(" "), files = namedFiles(steps.map((s) => s.named).join(" "), path);
550
600
  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 {}
551
601
  const on = waitOn(files.map((f) => f.name), settingsStep(step));
552
602
  out.yours = yours; if (files.length) out.waitFiles = files.map((f) => f.name);
@@ -565,13 +615,32 @@ function heldTasks(path) {
565
615
  // newest job per folder — the questions/ideas it left, any tasks held for it and
566
616
  // a step of yours it waits on. Then runs from before the app started that still
567
617
  // need you (earlierRuns).
618
+ // A run's work, to draw: parsed from its log (work.mjs), with how far through its
619
+ // TASKS.md it is. tail is what to show as text: what it said, for a streaming
620
+ // run; the log's end, for another agent.
621
+ // Claude Code in print mode (claude -p) streams each step as JSON, so the work
622
+ // can be drawn as it happens (work.mjs). Added to a claude -p command that has no
623
+ // output format of its own; anything else (an Orca tab, another agent) as it is.
624
+ function withStream(cmd) {
625
+ const c = String(cmd || "");
626
+ if (!/^\s*claude\b/.test(c) || !/\s-p\b|\s--print\b/.test(c) || /--output-format\b/.test(c)) return c;
627
+ return c + " --output-format stream-json --verbose";
628
+ }
629
+ function workOf(path, log) {
630
+ const w = parseRun(lastRunText(log)), md = readSymbiot(path, "TASKS.md");
631
+ const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
632
+ const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
633
+ const work = w.stream ? { model: w.model, doing: w.doing, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
634
+ return { tail, work, progress: total ? { done, total } : null };
635
+ }
568
636
  function agentsList() {
569
637
  const seen = new Set();
570
638
  return HANDOFFS.map((e) => {
571
- let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
639
+ let log = ""; try { log = readFileSync(e.log, "utf8"); } catch {}
640
+ const { tail, work, progress } = workOf(e.path, log);
572
641
  const first = !seen.has(e.path); seen.add(e.path);
573
- return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, fromHeld: !!e.fromHeld };
642
+ return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, fromHeld: !!e.fromHeld };
574
643
  }).concat(earlierRuns());
575
644
  }
576
645
 
577
- 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, skipIdea, agentQuestions, answerQuestions, agentsList };
646
+ export { withStream, workOf, 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 } : {}), steps: r.steps || [] };
72
+ }
73
+
74
+ export { homeState, homeContext, homeAsk };
package/lanes.mjs CHANGED
@@ -19,10 +19,11 @@ 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";
26
+ import { parseRun, lastRunText } from "./work.mjs";
26
27
  import { OPS, parseHandoffs } from "./handover.mjs";
27
28
 
28
29
  const LEDGER = join(CONFIG_DIR, "lanes.json");
@@ -83,7 +84,8 @@ function dispatch(path, { map = repoPathMap(), act = actNow, run = runHandoff, a
83
84
  // What an agent said last: its log after the newest run's header.
84
85
  function lastWords(path) {
85
86
  const log = readSym(path, "agent.log"), at = log.lastIndexOf("\n=== "); if (at < 0) return "";
86
- const run = log.slice(at).split("\n").slice(3).join("\n").trim(); // past the header and the command
87
+ const w = parseRun(lastRunText(log)); // a streaming run: its final answer, not its JSON
88
+ const run = w.stream ? (w.final || w.said.join("\n")) : log.slice(at).split("\n").slice(3).join("\n").trim(); // past the header and the command
87
89
  return run.length > TAIL ? "…" + run.slice(-TAIL) : run;
88
90
  }
89
91
  // When the newest run in a folder started (its log header), or 0.
@@ -109,14 +111,16 @@ function outcome(e, { tasks = loadTasks(), running = runningHandoff } = {}) {
109
111
  }
110
112
  // Tell the agent that asked: the result as an answer in its ANSWERS.md, then
111
113
  // 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() } = {}) {
114
+ // Not past a step of the user's it waits on (a key for .env, "don't start another
115
+ // run until it's in"): that run starts by itself once the step's done.
116
+ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = waitingFor, now = Date.now() } = {}) {
113
117
  const p = e.from.path, file = join(p, ".symbiot", "ANSWERS.md");
114
118
  try {
115
119
  mkdirSync(join(p, ".symbiot"), { recursive: true });
116
120
  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
121
  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
122
  } catch { return false; }
119
- if (!running(p)) run(p, { force: true });
123
+ if (!running(p)) run(p, { force: !waiting(p) });
120
124
  return true;
121
125
  }
122
126
 
package/mind.mjs CHANGED
@@ -26,6 +26,7 @@ import { handoffCmd, runHandoff } from "./agents.mjs";
26
26
  import { addTask, pushTasks } from "./tasks.mjs";
27
27
  import { repoPathMap } from "./scan.mjs";
28
28
  import { OPS, handoverRules, ONLY_YOU } from "./handover.mjs";
29
+ import { styleOf, styleLine, userStyleLine } from "./adapt.mjs";
29
30
 
30
31
  const MIND_FILE = join(CONFIG_DIR, "mind.json");
31
32
  const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
@@ -114,12 +115,14 @@ This folder isn't a repo; there's nothing to change in it but this file. Use you
114
115
 
115
116
  - **Anything hard to undo you only ask about:** closing or deleting an account, deleting data, paying or buying, sending something to someone. Find out what it would affect first (what's running, what it costs, what's in it), then stop and ask in \`.symbiot/QUESTIONS.md\` with what you found, and don't go ahead until ANSWERS.md says to.
116
117
  - Is part of it a repo's work? Hand it over (below) rather than doing it from here.
117
- - Need the user? Only for what no agent can do: ask in \`.symbiot/QUESTIONS.md\` (a \`## Questions\` heading, a \`### \` heading per question, then 2–4 options as \`- \` bullets).
118
+ - Need the user? Only for what no agent can do: ask in \`.symbiot/QUESTIONS.md\` (a \`## Questions\` heading, a \`### \` heading per question, then 2–4 options as \`- \` bullets).${voiceLine()}
118
119
  ${ONLY_YOU}
119
120
  - When it's done, tick the task (\`- [x]\`) and say what you did in your last message: it's what goes back to whoever asked.
120
121
 
121
122
  ${handoverRules(names, OPS).join("\n")}`;
122
123
  }
124
+ // How to write to the user, for a brief: their talking style, if it's known.
125
+ const voiceLine = () => { const v = userStyleLine(); return v ? "\n- Writing to the user (questions, your last message): " + v : ""; };
123
126
  // Run the coding agent on a request, in a folder of its own (it shows in the
124
127
  // Agents tab, with its questions). { ok, job, dir } or { error }.
125
128
  function actNow(request, { title, context, known, run = runHandoff, now = Date.now() } = {}) {
@@ -187,9 +190,16 @@ function parseReply(raw) {
187
190
  // own last turns. act: { agent(request) -> result, task(text, repo) -> result },
188
191
  // what "do" runs here. Gives { reply, did?, remembered }.
189
192
  async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
190
- const d = loadMind(), known = recallText(recall(question, now, d)), elsewhere = lately(where, d);
193
+ const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
194
+ // what it did to answer, to show under the reply (as the app shows an agent's work)
195
+ const steps = [];
196
+ if (hits.length) steps.push(`recalled ${hits.length === 1 ? hits[0].name : hits.length + " things: " + hits.slice(0, 3).map((h) => h.name).join(", ")}`);
197
+ const places = [...new Set(d.log.filter((l) => l.where !== where).slice(-6).map((l) => l.where))];
198
+ if (places.length) steps.push(`picked up what you said on ${places.slice(0, 2).join(" and ")}`);
191
199
  let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
192
- const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}`;
200
+ // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
201
+ const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
202
+ const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
193
203
  const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
194
204
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
195
205
  const raw = await ask(system, prompt);
@@ -214,7 +224,10 @@ async function converse({ where, role = "", context = "", history = "", question
214
224
  const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
215
225
  logTurn(where, "user", question, now, d2); logTurn(where, "ai", reply, now, d2);
216
226
  saveMind(d2);
217
- return { reply, ...(did ? { did } : {}), remembered };
227
+ if (voice) steps.push("matched how you talk");
228
+ if (did && !did.error) steps.push(did.kind === "agent" ? (did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
229
+ if (remembered) steps.push(`remembered ${remembered} new thing${remembered > 1 ? "s" : ""}`);
230
+ return { reply, ...(did ? { did } : {}), remembered, steps };
218
231
  }
219
232
  // The usual "task" act, for a caller with no lanes of its own to pass.
220
233
  const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };