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