symbiot 0.51.0 → 0.51.2
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 +18 -0
- package/README.md +53 -12
- package/agents.mjs +51 -8
- package/checks.mjs +651 -0
- package/home.mjs +120 -30
- package/knowledge.mjs +1 -1
- package/lanes.mjs +40 -6
- package/package.json +4 -3
- package/post.mjs +13 -8
- package/server.mjs +19 -7
- package/tasks.mjs +11 -1
- package/ui.mjs +405 -88
package/home.mjs
CHANGED
|
@@ -8,21 +8,48 @@
|
|
|
8
8
|
// The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
|
|
9
9
|
// how you use them; this is the live data around them. Home's talk goes to the
|
|
10
10
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
11
|
-
import {
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { watchBoard, watchState, isMail } from "./watch.mjs";
|
|
12
14
|
import { pendingReview, pushTasks } from "./tasks.mjs";
|
|
13
|
-
import { agentsList, runHandoff } from "./agents.mjs";
|
|
14
|
-
import {
|
|
15
|
+
import { agentsList, runHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
|
|
16
|
+
import { linksState } from "./links.mjs";
|
|
17
|
+
import { knowledgeState } from "./knowledge.mjs";
|
|
18
|
+
import { CONFIG_DIR, loadConfig, loadTasks } from "./core.mjs";
|
|
15
19
|
import { lanesState } from "./lanes.mjs";
|
|
16
20
|
import { converse, actIn, actNow, taskIn } from "./mind.mjs";
|
|
17
21
|
import { repoPathMap } from "./scan.mjs";
|
|
18
22
|
import { resolveProvider } from "./ai.mjs";
|
|
19
23
|
import { reportsNews } from "./reports.mjs";
|
|
24
|
+
import { awaitingState } from "./handback.mjs";
|
|
20
25
|
|
|
21
26
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
22
27
|
let cached = null;
|
|
28
|
+
const RUNS = join(CONFIG_DIR, "drafts"); // an ops run's folder (mind.mjs actNow): its own lane, not a repo
|
|
29
|
+
const RUNS_LANE = "ops"; // what the work scene calls them all, together
|
|
23
30
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
31
|
+
// A project's name as people say it: the folder can lag behind (CallForge AI is
|
|
32
|
+
// Dailify). The README's title when it's a short name, else package.json's name
|
|
33
|
+
// when it's a word, else the folder's. Read once per folder.
|
|
34
|
+
const NAMES = new Map();
|
|
35
|
+
function displayName(path, folder) {
|
|
36
|
+
folder = String(folder || ""); if (!path) return folder;
|
|
37
|
+
if (NAMES.has(path)) return NAMES.get(path);
|
|
38
|
+
const read = (f) => { try { return readFileSync(join(path, f), "utf8"); } catch { return ""; } };
|
|
39
|
+
const plainName = (s) => { s = String(s || "").replace(/[*`[\]]|^_+|_+$/g, "").trim(); return s && s.length <= 32 && s.split(/\s+/).length <= 4 && !/[:—–|]|\s-\s/.test(s) ? s : ""; };
|
|
40
|
+
const h1 = plainName((read("README.md").match(/^#\s+(.+?)\s*#*\s*$/m) || [])[1]);
|
|
41
|
+
let pkg = ""; try { pkg = String(JSON.parse(read("package.json")).name || "").replace(/^@[^/]+\//, ""); } catch {}
|
|
42
|
+
const name = h1 || (/^[a-z][a-z0-9-]{1,30}$/i.test(pkg) && pkg.toLowerCase() !== folder.toLowerCase().replace(/\s+/g, "-") ? pkg.charAt(0).toUpperCase() + pkg.slice(1) : "") || folder;
|
|
43
|
+
NAMES.set(path, name);
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
// The lane a folder is: the repo it is (by the scan's map), or an ops run.
|
|
47
|
+
const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
|
|
48
|
+
|
|
49
|
+
// { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
|
|
50
|
+
// lanes: [{ from, to, text, status, times }], working: n, at }
|
|
51
|
+
// An ask carries what its blob on home needs: the lane (repo, or "" for an ops
|
|
52
|
+
// run), its name as people say it, the question and its first two options.
|
|
26
53
|
function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
27
54
|
if (!fresh && !deps.board && cached && now - cached.at < KEEP) return cached;
|
|
28
55
|
const board = deps.board || (() => { try { return watchBoard(24, now); } catch { return { cards: [] }; } });
|
|
@@ -32,38 +59,86 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
32
59
|
const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
|
|
33
60
|
const connected = deps.connected || (() => !!resolveProvider());
|
|
34
61
|
const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
|
|
35
|
-
const
|
|
62
|
+
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
63
|
+
// can a reply be noticed? Email on (mail on this computer), or an inbox watched
|
|
64
|
+
const seesInbox = deps.seesInbox || (() => { try { return !!(loadConfig().mail || {}).enabled || watchState().watches.some((w) => isMail(w.url)); } catch { return true; } });
|
|
65
|
+
// the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
|
|
66
|
+
const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
|
|
67
|
+
const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
|
|
68
|
+
const named = deps.name || displayName;
|
|
69
|
+
const you = [], map = repos() || {};
|
|
70
|
+
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
71
|
+
// only when it's missing, and then first (urgent): nothing works without it.
|
|
36
72
|
// First run: what only a new user can do before the rest means anything.
|
|
37
|
-
if (!connected()) you.push({ kind: "setup", id: "setup:ai", title: "Connect an AI", sub: "a key, or a free local model", shape: "settings" });
|
|
38
|
-
|
|
73
|
+
if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: a key, or a free local model", shape: "settings", focus: "ai" });
|
|
74
|
+
const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
|
|
75
|
+
for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
|
|
76
|
+
if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
|
|
39
77
|
for (const r of pending()) {
|
|
40
78
|
if (!r.path || r.running) continue;
|
|
41
79
|
const n = (r.tasks || []).length, files = (r.files || []).length;
|
|
42
80
|
if (!n && !files) continue;
|
|
43
|
-
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" });
|
|
81
|
+
you.push({ kind: "approve", id: "approve:" + r.repo, repo: r.repo, name: named(r.path, 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" });
|
|
44
82
|
}
|
|
45
83
|
const list = agents(), seen = new Set();
|
|
46
84
|
for (const a of list) {
|
|
47
85
|
if (seen.has(a.path)) continue; seen.add(a.path);
|
|
48
|
-
const
|
|
49
|
-
if (q)
|
|
86
|
+
const qs = (a.ask && a.ask.questions) || [], q = qs[0];
|
|
87
|
+
if (!q) continue;
|
|
88
|
+
const lane = laneOfPath(a.path, map), repo = lane || (lane === "" ? "" : a.name);
|
|
89
|
+
you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
|
|
90
|
+
title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
|
|
50
91
|
}
|
|
92
|
+
// A run is waiting on an emailed reply that nothing will see: say so, once,
|
|
93
|
+
// until an inbox is watched or Email is on (handback.mjs looks in either).
|
|
94
|
+
if (waits().some((w) => w.status === "waiting") && !seesInbox()) you.push({ kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
|
|
51
95
|
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" }));
|
|
52
96
|
const rep = reports();
|
|
53
97
|
if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
|
|
54
|
-
const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status }));
|
|
98
|
+
const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
|
|
55
99
|
const working = list.filter((a) => a.status === "running").length;
|
|
56
|
-
const out = { you: you.slice(0, 6), feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
|
|
100
|
+
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
|
|
57
101
|
if (!deps.board) cached = out;
|
|
58
102
|
return out;
|
|
59
103
|
}
|
|
60
104
|
|
|
105
|
+
// Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
|
|
106
|
+
// found Settings one long page with nothing saying what comes first). Each is
|
|
107
|
+
// done or not from what's set up, so it ticks itself; the company folder is
|
|
108
|
+
// optional. The linked site says whether agent runs can use it too: only through
|
|
109
|
+
// Claude's own connector for it (a link signs in Symbiot's browser, not the run).
|
|
110
|
+
function firstSteps({ deps = {} } = {}) {
|
|
111
|
+
const tryOr = (f, d) => { try { return f(); } catch { return d; } };
|
|
112
|
+
const ai = tryOr(deps.connected || (() => !!resolveProvider()), false);
|
|
113
|
+
const repos = Object.keys(tryOr(deps.repos || repoPathMap, {}) || {}).length;
|
|
114
|
+
const cmd = String(tryOr(deps.cmd || handoffCmd, "") || "");
|
|
115
|
+
const site = ((tryOr(deps.links || linksState, {}) || {}).items || []).find((x) => x.state && x.state !== "off");
|
|
116
|
+
const conns = tryOr(deps.connectors || connectorsInfo, {}) || {};
|
|
117
|
+
const folders = ((tryOr(deps.knowledge || knowledgeState, {}) || {}).folders || []).length;
|
|
118
|
+
let runs = "";
|
|
119
|
+
if (site) {
|
|
120
|
+
const c = (conns.links || []).find((x) => x.id === site.id);
|
|
121
|
+
runs = !conns.claude ? "Agent runs can't use it: they get connectors only with Claude Code as your agent."
|
|
122
|
+
: c && c.ready ? `Agent runs can use it too, through Claude's ${c.connector || c.name} connector.`
|
|
123
|
+
: c ? `Agent runs can't use it yet: connect ${c.name} in claude.ai → Settings → Connectors (only you can sign in there).`
|
|
124
|
+
: "Agent runs can't use it: it signs in Symbiot's browser only, and Claude has no connector for it.";
|
|
125
|
+
}
|
|
126
|
+
const steps = [
|
|
127
|
+
{ id: "ai", title: "Connect an AI", done: ai, sub: ai ? "connected" : "a key, or a free local model" },
|
|
128
|
+
{ id: "work", title: "Where your work is", done: repos > 0, sub: repos ? `${repos} repo${repos > 1 ? "s" : ""} found` : "the folders your repos are in" },
|
|
129
|
+
{ id: "agent", title: "Your agent", done: !!cmd, sub: cmd ? "Send to repos hands tasks to it" : "the coding agent that takes your tasks" },
|
|
130
|
+
{ id: "site", title: "Link one site", done: !!site, sub: site ? `${site.name} linked. ${runs}` : "your mail, chat or code, so Symbiot sees what arrives" },
|
|
131
|
+
{ id: "company", title: "A company folder", optional: true, done: folders > 0, sub: folders ? "your chats can quote it, and Symbiot checks where its files disagree" : "documents your chats can quote, and Symbiot checks" },
|
|
132
|
+
];
|
|
133
|
+
return { steps, done: steps.every((s) => s.done) };
|
|
134
|
+
}
|
|
135
|
+
|
|
61
136
|
// One line per thing home shows, for the talk's context.
|
|
62
137
|
function homeContext(h) {
|
|
63
138
|
return [
|
|
64
139
|
`Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
|
|
65
140
|
`New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
|
|
66
|
-
`Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status})`) : ["- none"]),
|
|
141
|
+
`Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
|
|
67
142
|
].join("\n");
|
|
68
143
|
}
|
|
69
144
|
|
|
@@ -89,36 +164,51 @@ async function homeAsk(question, { ask, now = Date.now(), state } = {}) {
|
|
|
89
164
|
// there's anything a Go would start. The details (steps, files, cost) stay one
|
|
90
165
|
// tap away, in the Agents panel.
|
|
91
166
|
const plain = (s, n = 64) => { s = String(s || "").replace(/[\`*_#>]/g, "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : s; };
|
|
167
|
+
// Projects (the lanes) are sorted the way you'd look for them: what needs you
|
|
168
|
+
// first (a question, an Approve), then what's at work, ready, waiting, and the
|
|
169
|
+
// most recent. Ops runs (act-…: no repo of their own) are one "Agent runs" lane,
|
|
170
|
+
// not a sphere each with an id for a name.
|
|
92
171
|
function workScene({ deps = {} } = {}) {
|
|
93
172
|
const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
|
|
94
173
|
const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
|
|
95
174
|
const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
|
|
96
|
-
const
|
|
175
|
+
const map = (deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } }))() || {};
|
|
176
|
+
const named = deps.name || displayName;
|
|
177
|
+
const parked = new Set((deps.parked || (() => { try { return parkedPaths(); } catch { return []; } }))());
|
|
178
|
+
const isParked = (repo) => !!map[repo] && parked.has(map[repo]);
|
|
179
|
+
const laneOf = (a) => { const l = laneOfPath(a.path, map); return l === "" ? RUNS_LANE : l || a.name; };
|
|
180
|
+
const seen = new Set(), running = [], asks = {};
|
|
97
181
|
for (const a of agents()) {
|
|
98
|
-
if (
|
|
182
|
+
if (seen.has(a.path)) continue; seen.add(a.path);
|
|
183
|
+
const nq = (a.ask && a.ask.questions && a.ask.questions.length) || 0;
|
|
184
|
+
if (nq) asks[laneOf(a)] = (asks[laneOf(a)] || 0) + nq;
|
|
185
|
+
if (a.status !== "running") continue;
|
|
99
186
|
const w = a.work || {}, todo = (w.todos || []).find((t) => t.status === "in_progress"), pg = a.progress;
|
|
100
|
-
running.push({ id: "run:" + a.path, path: a.path, name: a.name, doing: plain(todo ? todo.active : w.doing || "Working on it", 120), progress: pg || null, waiting: !!
|
|
187
|
+
running.push({ id: "run:" + a.path, path: a.path, name: a.name, lane: laneOf(a), doing: plain(todo ? todo.active : w.doing || "Working on it", 120), progress: pg || null, waiting: !!nq, started: a.startedAt || (a.elapsed ? Date.now() - a.elapsed : 0) });
|
|
101
188
|
}
|
|
102
189
|
const ready = pending().filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length)).map((r) => ({ id: "ready:" + r.repo, repo: r.repo, count: (r.tasks || []).length }));
|
|
103
|
-
const busy = new Set(running.map((r) => r.
|
|
104
|
-
const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 160), repo: t.repo, busy: busy.has(t.repo) })); // the app shortens it for a tag
|
|
105
|
-
// Projects: each
|
|
106
|
-
const by = {}, at = (name) => (by[name] = by[name] || { repo: name, waiting: 0, ready: 0, running: null });
|
|
107
|
-
running.forEach((r) => { at(r.
|
|
190
|
+
const busy = new Set(running.map((r) => r.lane)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
|
|
191
|
+
const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 160), repo: t.repo, busy: busy.has(t.repo), ...(isParked(t.repo) ? { parked: true } : {}) })); // the app shortens it for a tag
|
|
192
|
+
// Projects: each lane with work on it, what's going on there in one line's worth
|
|
193
|
+
const by = {}, at = (name) => (by[name] = by[name] || { repo: name, name: name === RUNS_LANE ? "Agent runs" : named(map[name], name), waiting: 0, ready: 0, asks: 0, running: null, last: 0, ...(name === RUNS_LANE ? { runs: 0 } : {}) });
|
|
194
|
+
running.forEach((r) => { const p = at(r.lane); if (r.lane === RUNS_LANE) p.runs++; if (!p.running) p.running = { doing: r.doing, progress: r.progress, ask: r.waiting }; p.last = Math.max(p.last, r.started || 0); });
|
|
108
195
|
ready.forEach((r) => { at(r.repo).ready = r.count || 1; });
|
|
109
|
-
open.forEach((t) => { at(t.repo).waiting++; });
|
|
110
|
-
|
|
111
|
-
|
|
196
|
+
open.forEach((t) => { const p = at(t.repo); p.waiting++; p.last = Math.max(p.last, Number(t.ts) || 0); });
|
|
197
|
+
Object.keys(asks).forEach((l) => { at(l).asks = asks[l]; });
|
|
198
|
+
for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
|
|
199
|
+
const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
|
|
200
|
+
return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
|
|
112
201
|
}
|
|
113
202
|
// Go: everything waiting goes to its repo's agent, the way Send to repos and an
|
|
114
203
|
// agent per repo would: briefs written, an agent started in each (or queued
|
|
115
|
-
// behind one already there)
|
|
204
|
+
// behind one already there); a parked project's wait for it to be unparked.
|
|
205
|
+
// { started, queued, repos, parked? } or { error }.
|
|
116
206
|
function workGo({ push = pushTasks, run = runHandoff } = {}) {
|
|
117
207
|
const r = push({});
|
|
118
208
|
if (r.empty) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
|
|
119
|
-
let started = 0, queued = 0; const repos = [];
|
|
120
|
-
for (const w of r.written || []) { const e = run(w.path); if (e && e.id && !e.busy && !e.blocked) started++; else queued++; repos.push(w.name); }
|
|
121
|
-
return { started, queued, repos, ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
|
|
209
|
+
let started = 0, queued = 0; const repos = [], parked = [];
|
|
210
|
+
for (const w of r.written || []) { const e = run(w.path); if (e && e.parked) { parked.push(w.name); continue; } if (e && e.id && !e.busy && !e.blocked) started++; else queued++; repos.push(w.name); }
|
|
211
|
+
return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
|
|
122
212
|
}
|
|
123
213
|
|
|
124
|
-
export { homeState, homeContext, homeAsk, workScene, workGo };
|
|
214
|
+
export { homeState, homeContext, homeAsk, workScene, workGo, displayName, firstSteps };
|
package/knowledge.mjs
CHANGED
|
@@ -358,4 +358,4 @@ function knowledgeState({ file = KNOW_FILE, cfg = loadConfig() } = {}) {
|
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
export { KNOW_FILE, DEFAULT_EXAMPLES, knowledgeFolders, addKnowledgeFolder, removeKnowledgeFolder, isExample, frontMatter, csvRows, indexKnowledge, knowledgeTick, searchKnowledge, myName, setMyName, namesYou, waitingOn, ownerOf, knowledgeFor, knowledgeState, itemLine, caseLine };
|
|
361
|
+
export { KNOW_FILE, DEFAULT_EXAMPLES, READ, MAX_BYTES, walk, cite, knowledgeFolders, addKnowledgeFolder, removeKnowledgeFolder, isExample, frontMatter, csvRows, indexKnowledge, knowledgeTick, searchKnowledge, myName, setMyName, namesYou, waitingOn, ownerOf, knowledgeFor, knowledgeState, itemLine, caseLine };
|
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, clipWords } from "./core.mjs";
|
|
22
|
-
import { runHandoff, runningHandoff, waitingFor } from "./agents.mjs";
|
|
22
|
+
import { runHandoff, runningHandoff, waitingFor, agentQuestions } 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";
|
|
@@ -91,6 +91,17 @@ function lastWords(path) {
|
|
|
91
91
|
// When the newest run in a folder started (its log header), or 0.
|
|
92
92
|
function lastRunStart(path) { const log = readSym(path, "agent.log"), m = [...log.matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop(); return m ? Date.parse(m[1]) || 0 : 0; }
|
|
93
93
|
const openQuestions = (path) => /^###\s+/m.test(readSym(path, "QUESTIONS.md").split(/^##\s+Suggestions/m)[0]);
|
|
94
|
+
// A repo lane's run does a whole brief, so its last words cover every task in it,
|
|
95
|
+
// not just the one handed over (ops heard about a first-run audit and a posting
|
|
96
|
+
// test, three times, in reply to its four gaps). Only the lines about this one
|
|
97
|
+
// go back: those sharing most of its words. "" when it said nothing about it.
|
|
98
|
+
const COMMON = new Set(["with", "that", "this", "from", "have", "will", "your", "them", "then", "what", "when", "into", "about", "there", "their", "which", "would", "could", "should", "while", "please"]);
|
|
99
|
+
const keyWords = (s) => new Set(String(s || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3 && !COMMON.has(w)).map((w) => w.replace(/s$/, "")));
|
|
100
|
+
function aboutIt(words, text) {
|
|
101
|
+
const want = keyWords(firstLine(text)); if (want.size < 2) return words;
|
|
102
|
+
const hits = String(words || "").split(/\n+/).filter((l) => { const has = keyWords(l); let n = 0; for (const w of want) if (has.has(w)) n++; return n >= 2 && n / want.size >= 0.25; });
|
|
103
|
+
return hits.join("\n").trim();
|
|
104
|
+
}
|
|
94
105
|
|
|
95
106
|
// Is it done? Ops: its run ended (ticked or not). A repo: the task it was given
|
|
96
107
|
// is ticked or its run ended, and no agent is working there. Waiting on the
|
|
@@ -106,8 +117,20 @@ function outcome(e, { tasks = loadTasks(), running = runningHandoff } = {}) {
|
|
|
106
117
|
return { text: `${ticked ? "Done" : "It stopped without finishing"} (the ops agent).${words ? `\n\nWhat it said:\n${words}` : ""}` };
|
|
107
118
|
}
|
|
108
119
|
const t = tasks.find((x) => x.id === e.task);
|
|
109
|
-
const done = t && (t.review || t.done || t.archived);
|
|
110
|
-
return { text: `${done ? `Done in ${e.to.lane}: the task is ticked, and its changes wait for the user's review in Symbiot` : `${e.to.lane}'s agent stopped without ticking it`}.${
|
|
120
|
+
const done = t && (t.review || t.done || t.archived), about = aboutIt(words, e.text);
|
|
121
|
+
return { text: `${done ? `Done in ${e.to.lane}: the task is ticked, and its changes wait for the user's review in Symbiot` : `${e.to.lane}'s agent stopped without ticking it`}.${about ? `\n\nWhat it said about this:\n${about}` : words ? `\n\nIts last message was about other work in ${e.to.lane}, not this.` : ""}` };
|
|
122
|
+
}
|
|
123
|
+
// A run that stopped partway: questions to the user still open, or work it
|
|
124
|
+
// handed to another lane that hasn't come back (the ledger, or HANDOFF.md not
|
|
125
|
+
// picked up yet). The review card says what it waits on, rather than calling its
|
|
126
|
+
// changes "uncommitted, with no ticked task". null when it waits on nothing.
|
|
127
|
+
function partlyDone(path, { ledger = loadLedger() } = {}) {
|
|
128
|
+
if (!path) return null;
|
|
129
|
+
let questions = 0; try { questions = agentQuestions(path, "").questions.length; } catch {}
|
|
130
|
+
const mine = ledger.handoffs.filter((h) => h.from.path === path);
|
|
131
|
+
const handed = mine.filter((h) => !h.reportedAt && h.status !== "error").map((h) => ({ lane: h.to.lane, text: clipWords(firstLine(h.text), 90) }));
|
|
132
|
+
for (const h of parseHandoffs(readSym(path, "HANDOFF.md"))) if (!mine.some((x) => x.to.lane.toLowerCase() === h.lane.toLowerCase() && x.text === h.text)) handed.push({ lane: h.lane, text: clipWords(firstLine(h.text), 90) });
|
|
133
|
+
return questions || handed.length ? { questions, handed: handed.slice(0, 3) } : null;
|
|
111
134
|
}
|
|
112
135
|
// Tell the agent that asked: the result as an answer in its ANSWERS.md, then
|
|
113
136
|
// start it again to carry on (unless it's still running: it reads it next time).
|
|
@@ -141,7 +164,18 @@ function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, runnin
|
|
|
141
164
|
return { started, reported };
|
|
142
165
|
}
|
|
143
166
|
// The Agents tab's list: newest first. Each shows its first line; full, all of a
|
|
144
|
-
// longer one, and result, all of what came back, open under it.
|
|
145
|
-
|
|
167
|
+
// longer one, and result, all of what came back, open under it. What reads the
|
|
168
|
+
// same (one lane to the same lane, the same first line: a run that handed it
|
|
169
|
+
// over twice) is one row, the newest, with how many times (times).
|
|
170
|
+
function lanesState() {
|
|
171
|
+
const out = [], byKey = new Map();
|
|
172
|
+
for (const e of loadLedger().handoffs.slice().reverse()) {
|
|
173
|
+
const k = [e.from.lane, e.to.lane, firstLine(e.text).replace(/\s+/g, " ").trim()].join("\n").toLowerCase();
|
|
174
|
+
if (byKey.has(k)) { byKey.get(k).times++; continue; }
|
|
175
|
+
const row = { id: e.id, from: e.from.lane, to: e.to.lane, text: clipWords(firstLine(e.text), 160), ...(e.text.trim() !== firstLine(e.text).trim() || e.text.length > 160 ? { full: e.text } : {}), at: e.at, status: e.status, ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}), times: 1 };
|
|
176
|
+
byKey.set(k, row); out.push(row);
|
|
177
|
+
}
|
|
178
|
+
return { handoffs: out.slice(0, 30) };
|
|
179
|
+
}
|
|
146
180
|
|
|
147
|
-
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState };
|
|
181
|
+
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.51.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
3
|
+
"version": "0.51.2",
|
|
4
|
+
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"symbiot": "index.mjs"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"desktop.mjs",
|
|
34
34
|
"handback.mjs",
|
|
35
35
|
"knowledge.mjs",
|
|
36
|
+
"checks.mjs",
|
|
36
37
|
"post.mjs",
|
|
37
38
|
"reports.mjs",
|
|
38
39
|
"sandbox.mjs",
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
74
75
|
},
|
|
75
76
|
"scripts": {
|
|
76
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/lanes.mjs && node test/handback.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
77
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
77
78
|
"lint": "eslint ."
|
|
78
79
|
},
|
|
79
80
|
"devDependencies": {
|
package/post.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { join, dirname } from "node:path";
|
|
|
22
22
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
23
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
24
24
|
import { randomBytes } from "node:crypto";
|
|
25
|
-
import { CONFIG_DIR, sh, hasCmd } from "./core.mjs";
|
|
25
|
+
import { CONFIG_DIR, sh, hasCmd, loadConfig } from "./core.mjs";
|
|
26
26
|
import { resolveProvider, write } from "./ai.mjs";
|
|
27
27
|
import { commits, discoveredRepos } from "./scan.mjs";
|
|
28
28
|
import { readTexts } from "./headless.mjs";
|
|
@@ -41,7 +41,10 @@ const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of
|
|
|
41
41
|
|
|
42
42
|
// ---- your voice ------------------------------------------------------------------
|
|
43
43
|
// voice.md's examples: what's between lines of ---, each long enough to be a post.
|
|
44
|
-
|
|
44
|
+
// LinkedIn's page text puts a "hashtag" line (a label for screen readers) over each
|
|
45
|
+
// #tag; read into voice.md, the drafts copied it under every tag. Dropped wherever seen.
|
|
46
|
+
const noTagLabels = (t) => String(t || "").replace(/^[ \t]*hashtag[ \t]*\r?\n(?=[ \t]*#)/gim, "");
|
|
47
|
+
function voiceOf(text) { return noTagLabels(text).split(/^\s*-{3,}\s*$/m).map((s) => s.trim()).filter((s) => s.length >= 20); }
|
|
45
48
|
function loadVoice(file = PATHS.voice) { try { return voiceOf(readFileSync(file, "utf8")); } catch { return []; } }
|
|
46
49
|
function saveText(file, text) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text, { mode: 0o600 }); try { chmodSync(file, 0o600); } catch {} }
|
|
47
50
|
|
|
@@ -50,7 +53,7 @@ function saveText(file, text) { mkdirSync(dirname(file), { recursive: true }); w
|
|
|
50
53
|
// runs by itself. `read` is headless.mjs readTexts (the tests pass their own).
|
|
51
54
|
const LINKEDIN_ACTIVITY = "https://www.linkedin.com/in/me/recent-activity/shares/";
|
|
52
55
|
const LINKEDIN_POST_TEXT = ".update-components-text, .feed-shared-update-v2__description, .feed-shared-text";
|
|
53
|
-
const tidyPost = (t) =>
|
|
56
|
+
const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
54
57
|
async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
|
|
55
58
|
const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
|
|
56
59
|
if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
|
|
@@ -173,7 +176,7 @@ function extractJson(s) {
|
|
|
173
176
|
// The AI's answer as drafts, by kind: [{ kind, text, facts: [n] }].
|
|
174
177
|
function parseDrafts(raw) {
|
|
175
178
|
const j = extractJson(raw), list = j && Array.isArray(j.posts) ? j.posts : [];
|
|
176
|
-
return list.map((p, i) => ({ kind: KINDS.includes(p && p.kind) ? p.kind : KINDS[i] || "", text:
|
|
179
|
+
return list.map((p, i) => ({ kind: KINDS.includes(p && p.kind) ? p.kind : KINDS[i] || "", text: noTagLabels((p && p.text) || "").trim().slice(0, MAX_TEXT), facts: (Array.isArray(p && p.facts) ? p.facts : []).map(Number).filter(Number.isInteger) }))
|
|
177
180
|
.filter((p) => p.kind && p.text);
|
|
178
181
|
}
|
|
179
182
|
// A draft checked against the facts: the facts it cites that exist, and what it
|
|
@@ -244,11 +247,13 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
|
|
|
244
247
|
}
|
|
245
248
|
function postLog(paths = PATHS) { try { return readFileSync(paths.log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }
|
|
246
249
|
// What the Dashboard shows: the drafts waiting on you, the last few you dealt with,
|
|
247
|
-
// and whether it can draft
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
// and whether it can draft: an AI connected, and your voice (examples in voice.md,
|
|
251
|
+
// or LinkedIn linked to fill it from). Until then the Dashboard says so in a line.
|
|
252
|
+
function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
|
|
253
|
+
const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
|
|
254
|
+
let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
|
|
250
255
|
return { posts: d.posts.filter((p) => p.status === "waiting"), done: d.posts.filter((p) => p.status === "approved" || p.status === "skipped").slice(0, 5),
|
|
251
|
-
voice: { count:
|
|
256
|
+
voice: { count: voice, file: paths.voice }, connected, linkedin, canDraft: connected && (linkedin || voice > 0), share: SHARE_URL, labels: KIND_LABEL };
|
|
252
257
|
}
|
|
253
258
|
// One waiting draft, by id (or the start of one), and the store it's in.
|
|
254
259
|
function waiting(id, paths) {
|
package/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { createServer } from "node:http";
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
14
14
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
15
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } from "./agents.mjs";
|
|
15
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths } from "./agents.mjs";
|
|
16
16
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
17
17
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
18
18
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -25,13 +25,14 @@ import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, chec
|
|
|
25
25
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
26
26
|
import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn } from "./post.mjs";
|
|
27
27
|
import { mindState, forget } from "./mind.mjs";
|
|
28
|
-
import { lanesTick, lanesState } from "./lanes.mjs";
|
|
28
|
+
import { lanesTick, lanesState, partlyDone } from "./lanes.mjs";
|
|
29
29
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
30
30
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
31
|
-
import { homeState, homeAsk, workScene, workGo } from "./home.mjs";
|
|
31
|
+
import { homeState, homeAsk, workScene, workGo, firstSteps } from "./home.mjs";
|
|
32
32
|
import { listReports, readReport, markAllRead } from "./reports.mjs";
|
|
33
33
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
34
34
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
35
|
+
import { runChecks, checksState } from "./checks.mjs";
|
|
35
36
|
|
|
36
37
|
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
37
38
|
// npm's cache/propagation can resolve stale — that caused an update loop where the
|
|
@@ -218,9 +219,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
218
219
|
if (u.pathname === "/api/pending") { // ticked by the agent, awaiting approval
|
|
219
220
|
// what npm has decides the bump offer, and for a repo that publishes on
|
|
220
221
|
// merge, what's unreleased: ask, then recount (answers are cached)
|
|
222
|
+
// partly: what a run that stopped partway still waits on (its questions, a handover)
|
|
223
|
+
const partly = (l) => l.map((r) => { const p = r.path ? partlyDone(r.path) : null; return p ? { ...r, partly: p } : r; });
|
|
221
224
|
const list = pendingReview(), ask = list.filter((r) => r.path && (!r.bumpOffer || r.publishesOnMerge)).map((r) => r.path);
|
|
222
|
-
if (!ask.length) return json(res, list);
|
|
223
|
-
await learnNpm(ask); return json(res, pendingReview());
|
|
225
|
+
if (!ask.length) return json(res, partly(list));
|
|
226
|
+
await learnNpm(ask); return json(res, partly(pendingReview()));
|
|
224
227
|
}
|
|
225
228
|
if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
226
229
|
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
|
|
@@ -234,8 +237,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
234
237
|
// knowledge folders (knowledge.mjs): added or removed, the index catches up at once
|
|
235
238
|
if (u.pathname === "/api/knowledge") return json(res, knowledgeState());
|
|
236
239
|
if (u.pathname === "/api/knowledge/add" && req.method === "POST") { const b = await readBody(req), r = addKnowledgeFolder(String(b.path || ""), b.examples); if (r.error) return json(res, r); indexKnowledge(); return json(res, { ok: true, ...knowledgeState() }); }
|
|
237
|
-
if (u.pathname === "/api/knowledge/remove" && req.method === "POST") { const b = await readBody(req), r = removeKnowledgeFolder(String(b.path || "")); if (r.error) return json(res, r); indexKnowledge(); return json(res, { ok: true, ...knowledgeState() }); }
|
|
240
|
+
if (u.pathname === "/api/knowledge/remove" && req.method === "POST") { const b = await readBody(req), r = removeKnowledgeFolder(String(b.path || "")); if (r.error) return json(res, r); indexKnowledge(); try { runChecks(); } catch {} return json(res, { ok: true, ...knowledgeState() }); }
|
|
238
241
|
if (u.pathname === "/api/knowledge/index" && req.method === "POST") { const r = indexKnowledge(); return json(res, { ...r, ...knowledgeState() }); }
|
|
242
|
+
// where two of the folders' files disagree (checks.mjs): the last result, or checked again now
|
|
243
|
+
if (u.pathname === "/api/knowledge/checks") return json(res, checksState());
|
|
244
|
+
if (u.pathname === "/api/knowledge/checks/run" && req.method === "POST") return json(res, runChecks());
|
|
239
245
|
if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
|
|
240
246
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
|
|
241
247
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
@@ -318,11 +324,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
318
324
|
// the work scene: what agents are doing, what's waiting, what's ready; Go starts what's waiting
|
|
319
325
|
if (u.pathname === "/api/work") return json(res, workScene());
|
|
320
326
|
if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
|
|
327
|
+
if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
|
|
321
328
|
if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
|
|
322
329
|
if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
|
|
323
330
|
if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
|
|
324
331
|
// Lanes (lanes.mjs): work agents handed to each other, and where it stands.
|
|
325
332
|
if (u.pathname === "/api/lanes") return json(res, lanesState());
|
|
333
|
+
// a parked project (lane) starts no agent runs until it's unparked (agents.mjs parkLane)
|
|
334
|
+
if (u.pathname === "/api/lanes/parked") { const map = repoPathMap(), ps = parkedPaths(); return json(res, { repos: Object.keys(map).filter((n) => ps.includes(map[n])), paths: ps }); }
|
|
335
|
+
if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map = repoPathMap(), p = Object.values(map).includes(String(b.path || "")) ? String(b.path) : map[String(b.repo || "")]; if (!p) return json(res, { error: "That project isn't on this computer." }); return json(res, parkLane(p, b.on !== false)); }
|
|
326
336
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
327
337
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
328
338
|
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
@@ -414,7 +424,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
414
424
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
415
425
|
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
416
426
|
setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
|
|
417
|
-
|
|
427
|
+
// knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
|
|
428
|
+
const knowTick = () => { try { const r = knowledgeTick(); if (r && (r.read || r.removed || !checksState().at)) runChecks(); } catch {} };
|
|
429
|
+
setTimeout(knowTick, 5000).unref(); setInterval(knowTick, 3 * 60 * 1000).unref();
|
|
418
430
|
setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
|
|
419
431
|
}
|
|
420
432
|
|
package/tasks.mjs
CHANGED
|
@@ -574,7 +574,17 @@ function buildTasksMd(name, ctx, list) {
|
|
|
574
574
|
function pushTasks(filter) {
|
|
575
575
|
const byName = repoPathMap(); // from the already-scanned map when there is one
|
|
576
576
|
// what an approved Drop/Merge named, or an agent deleted from its brief, never goes out again
|
|
577
|
-
const all = loadTasks();
|
|
577
|
+
const all = loadTasks(); let changed = applyRemovals(all) + applyDrops(all, byName);
|
|
578
|
+
// what an agent ticked since the Tasks tab last looked goes to review first, as
|
|
579
|
+
// syncTasks would: a new brief over TASKS.md would wipe its ticks, and finished
|
|
580
|
+
// work went out again as new tasks (7 of symbiot's, 2026-10-07)
|
|
581
|
+
const ticked = {};
|
|
582
|
+
for (const t of all) {
|
|
583
|
+
if (t.done || t.archived || t.review || !t.repo || !byName[t.repo]) continue;
|
|
584
|
+
if (!(t.repo in ticked)) ticked[t.repo] = completedInRepo(byName[t.repo]);
|
|
585
|
+
if (ticked[t.repo].some((c) => sameTask(c, t.text))) { t.review = true; t.reviewAt = Date.now(); changed++; }
|
|
586
|
+
}
|
|
587
|
+
if (changed) saveTasks(all);
|
|
578
588
|
let tasks = all.filter((t) => !t.done && !t.archived && !t.review);
|
|
579
589
|
if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
|
|
580
590
|
if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
|