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/CHANGELOG.md +458 -0
- package/README.md +63 -2
- package/adapt.mjs +222 -0
- package/agents.mjs +80 -11
- package/core.mjs +18 -1
- package/headless.mjs +8 -0
- package/home.mjs +74 -0
- package/lanes.mjs +8 -4
- package/mind.mjs +17 -4
- package/package.json +6 -2
- package/server.mjs +76 -6
- package/tasks.mjs +57 -7
- package/ui.mjs +349 -15
- package/watch.mjs +48 -19
- package/work.mjs +113 -0
- package/writeups.mjs +33 -3
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
|
-
//
|
|
94
|
-
|
|
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")
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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 }); };
|