symbiot 0.55.0 → 0.55.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 +133 -52
- package/agents.mjs +136 -6
- package/away.mjs +18 -12
- package/guard.mjs +6 -2
- package/handback.mjs +2 -2
- package/handover.mjs +14 -3
- package/home.mjs +183 -33
- package/index.mjs +4 -4
- package/lanes.mjs +6 -6
- package/marketing.mjs +130 -0
- package/mind.mjs +9 -9
- package/package.json +4 -3
- package/reports.mjs +3 -3
- package/scan.mjs +9 -2
- package/server.mjs +28 -13
- package/tasks.mjs +14 -10
- package/ui.mjs +309 -128
- package/watch.mjs +5 -5
- package/writeups.mjs +3 -3
package/away.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// symbiot — Away: Symbiot full screen on every screen while you're away from the
|
|
2
|
-
// desk (Super
|
|
2
|
+
// desk (Super+`, or `symbiot away`): the orb at rest, the time, and what's going on
|
|
3
3
|
// (agents at work and what they're doing, how much waits on you), counts only, no
|
|
4
4
|
// one's words. One screen: the orb in the middle. More: it bounces across all of
|
|
5
5
|
// them, one kiosk window per screen, each working out where the orb is from the
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// own move-to-screen shortcut). So under Wayland it's one window, full screen where
|
|
12
12
|
// you are, the orb in the middle. Each window's profile lets Symbiot's address see the
|
|
13
13
|
// screens and go full screen without a click, so nothing asks.
|
|
14
|
-
// Any key or click, or Super
|
|
14
|
+
// Any key or click, or Super+` again, closes every window. Not a lock: Super+Esc
|
|
15
15
|
// locks the computer.
|
|
16
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
17
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
@@ -65,6 +65,7 @@ function grantProfile(dir, origin) {
|
|
|
65
65
|
const placeable = (env = process.env) => (env.XDG_SESSION_TYPE || "").toLowerCase() === "x11" || (!env.WAYLAND_DISPLAY && !!env.DISPLAY);
|
|
66
66
|
function openAway(url, { chrome = chromeBinary(), scr = placeable() ? screens() : { list: [], display: "" }, now = Date.now() } = {}) {
|
|
67
67
|
if (!chrome) return { error: "Away needs Chrome or Chromium on this computer." };
|
|
68
|
+
if (process.env.SYMBIOT_NO_OPEN === "1") return { open: false, note: "Windows are off here (SYMBIOT_NO_OPEN): tests, and runs that mustn't open any." };
|
|
68
69
|
closeAway();
|
|
69
70
|
const qs = awayQueries(scr.list, now);
|
|
70
71
|
const origin = (String(url).match(/^https?:\/\/[^/?#]+/) || [""])[0];
|
|
@@ -77,31 +78,36 @@ function openAway(url, { chrome = chromeBinary(), scr = placeable() ? screens()
|
|
|
77
78
|
});
|
|
78
79
|
return { open: KIDS.length > 0, screens: Math.max(1, scr.list.length) };
|
|
79
80
|
}
|
|
80
|
-
//
|
|
81
|
+
// open: true opens, false closes, "toggle" goes the other way; anything else only
|
|
82
|
+
// says whether it's open (a call that names nothing never opens windows: the smoke
|
|
83
|
+
// test calls every endpoint, and it opened Away on the owner's screen every run).
|
|
81
84
|
function toggleAway(url, open, deps) {
|
|
82
|
-
|
|
85
|
+
if (open !== true && open !== false && open !== "toggle") return { open: awayOpen() };
|
|
86
|
+
const want = open === "toggle" ? !awayOpen() : open;
|
|
83
87
|
if (!want) { closeAway(); return { open: false }; }
|
|
84
88
|
return openAway(url, deps);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
// ---- the shortcut -------------------------------------------------------------
|
|
88
|
-
// Super
|
|
89
|
-
//
|
|
90
|
-
//
|
|
92
|
+
// Super+` (the key above Tab) runs `symbiot away`: free on COSMIC, where Super+S
|
|
93
|
+
// stacks windows. COSMIC: a custom shortcut in its config (it reloads it as it
|
|
94
|
+
// changes). Elsewhere it says how to add one.
|
|
91
95
|
const COSMIC_CUSTOM = (home = homedir()) => join(home, ".config", "cosmic", "com.system76.CosmicSettings.Shortcuts", "v1", "custom");
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
const KEY = "grave";
|
|
97
|
+
function shortcutLine(cmd) { return ` (modifiers: [Super], key: "${KEY}"): Spawn(${JSON.stringify(cmd)}),`; }
|
|
98
|
+
// The new custom file: whatever was there, minus any Super+` and any earlier one of
|
|
99
|
+
// ours (on another key), plus ours.
|
|
94
100
|
function withShortcut(text, cmd) {
|
|
95
101
|
const body = String(text || "").trim().replace(/^\{/, "").replace(/\}\s*$/, "");
|
|
96
|
-
const kept = body.split("\n").filter((l) => l.trim() && !/\(modifiers:\s*\[\s*Super\s*,?\s*\],\s*key:\s*"
|
|
102
|
+
const kept = body.split("\n").filter((l) => l.trim() && !/\(modifiers:\s*\[\s*Super\s*,?\s*\],\s*key:\s*"grave"\s*\)/.test(l) && !/Spawn\("[^"]*symbiot[^"]* away"\)/.test(l));
|
|
97
103
|
return "{\n" + [...kept.map((l) => l.replace(/\s*$/, "")), shortcutLine(cmd)].join("\n") + "\n}\n";
|
|
98
104
|
}
|
|
99
105
|
function installShortcut({ cmd, home = homedir(), desktop = process.env.XDG_CURRENT_DESKTOP || "" } = {}) {
|
|
100
|
-
if (!/cosmic/i.test(desktop)) return { manual: true, note: `Add a keyboard shortcut in your desktop's settings: Super
|
|
106
|
+
if (!/cosmic/i.test(desktop)) return { manual: true, note: `Add a keyboard shortcut in your desktop's settings: Super+\` runs ${cmd}` };
|
|
101
107
|
const f = COSMIC_CUSTOM(home);
|
|
102
108
|
let had = ""; try { had = existsSync(f) ? readFileSync(f, "utf8") : ""; } catch {}
|
|
103
109
|
try { mkdirSync(join(f, ".."), { recursive: true }); writeFileSync(f, withShortcut(had, cmd)); } catch (e) { return { error: "Couldn't write " + f + ": " + ((e && e.message) || e) }; }
|
|
104
|
-
return { ok: true, file: f, replaced: /key:\s*"
|
|
110
|
+
return { ok: true, file: f, replaced: /key:\s*"grave"/.test(had) };
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
export { placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
|
package/guard.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// - delete outside the agent's own folder, or wipe a disk
|
|
9
9
|
// - sudo, or pipe something from the internet into a shell
|
|
10
10
|
// - read SSH keys or cloud credentials; change Symbiot's own settings
|
|
11
|
+
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
12
|
+
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
11
13
|
// judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
|
|
12
14
|
import { resolve, join, relative } from "node:path";
|
|
13
15
|
import { homedir } from "node:os";
|
|
@@ -61,7 +63,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
61
63
|
return null;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch } = {}) {
|
|
66
|
+
function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch, writes = null } = {}) {
|
|
65
67
|
const t = String(tool || "");
|
|
66
68
|
if (t === "Bash") return judgeBash(input.command, { cwd, home, branch: branch != null ? branch : currentBranch(cwd) });
|
|
67
69
|
const p = input.file_path || input.path || input.notebook_path;
|
|
@@ -69,6 +71,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
|
|
|
69
71
|
const abs = expand(p, home, cwd);
|
|
70
72
|
if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
|
|
71
73
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
|
|
74
|
+
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
|
|
72
75
|
return null;
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -78,7 +81,8 @@ const main = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(impor
|
|
|
78
81
|
if (main) {
|
|
79
82
|
let raw = ""; try { raw = readFileSync(0, "utf8"); } catch {}
|
|
80
83
|
let ev = {}; try { ev = JSON.parse(raw); } catch {}
|
|
81
|
-
|
|
84
|
+
let writes = null; try { writes = JSON.parse(process.env.SYMBIOT_WRITES || "null"); } catch {} // a sandboxed repo run's folders
|
|
85
|
+
const r = judge(ev.tool_name, ev.tool_input || {}, { cwd: ev.cwd || process.cwd(), writes });
|
|
82
86
|
if (r) { process.stderr.write("Blocked by Symbiot's membrane: " + r.why + ". If it's needed, ask the user in .symbiot/QUESTIONS.md with options.\n"); process.exit(2); }
|
|
83
87
|
process.exit(0);
|
|
84
88
|
}
|
package/handback.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { remember, actIn } from "./mind.mjs";
|
|
|
33
33
|
import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
|
|
34
34
|
import { inboxMail } from "./mail.mjs";
|
|
35
35
|
import { desktopNotify } from "./desktop.mjs";
|
|
36
|
-
import {
|
|
36
|
+
import { laneMap } from "./scan.mjs";
|
|
37
37
|
|
|
38
38
|
const AWAIT_FILE = join(CONFIG_DIR, "awaiting.json");
|
|
39
39
|
const RUNS_DIR = join(CONFIG_DIR, "drafts"); // runs of their own (act-…) and drafted replies
|
|
@@ -151,7 +151,7 @@ function handOn(w, r, { map, act = actIn, notify = desktopNotify, now = Date.now
|
|
|
151
151
|
// One pass, from the app every minute: take in new waits, look for their
|
|
152
152
|
// replies, and hand on the ones that came in. `news`, `inbox`, `act`, `notify`
|
|
153
153
|
// and `running` can be given (the tests do).
|
|
154
|
-
function awaitTick({ map =
|
|
154
|
+
function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, act, notify, running } = {}) {
|
|
155
155
|
const d = loadWaits(), added = collectWaits(d, { map, running, now }), open = d.waits.filter((w) => w.status === "waiting");
|
|
156
156
|
const replied = [];
|
|
157
157
|
if (open.length) {
|
package/handover.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// buildTasksMd, mind.mjs actBrief), by lanes.mjs and by handback.mjs.
|
|
8
8
|
|
|
9
9
|
const OPS = "ops";
|
|
10
|
+
// Marketing across every product, a lane of its own too (marketing.mjs)
|
|
11
|
+
const MARKETING = "marketing";
|
|
10
12
|
const MAX_LANES_LISTED = 40;
|
|
11
13
|
// How long a handover can be: an email to draft, quoted whole, fits. 4000 cut
|
|
12
14
|
// the one to ops mid-email (a repo lane's task links to the rest: addTask).
|
|
@@ -15,10 +17,11 @@ const HANDOVER_MAX = 20000;
|
|
|
15
17
|
// The lines a brief gets about lanes. here: this agent's lane; lanes: the others
|
|
16
18
|
// it can hand to (repo folder names).
|
|
17
19
|
function handoverRules(lanes = [], here = "") {
|
|
18
|
-
const others = [...new Set(lanes.filter((l) => l && l !== here))].slice(0, MAX_LANES_LISTED);
|
|
20
|
+
const mk = lanes.includes(MARKETING) && here !== MARKETING, others = [...new Set(lanes.filter((l) => l && l !== here && l !== MARKETING))].slice(0, MAX_LANES_LISTED);
|
|
19
21
|
return ["## Hand over what's another lane's job",
|
|
20
22
|
`Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it, and its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
|
|
21
23
|
`- \`${OPS}\`: anything outside a repo: this computer (find or install a tool, a JDK, an SDK), accounts and services (cloud, GitHub, DNS, email), the user's connectors.`,
|
|
24
|
+
...(mk ? [`- \`${MARKETING}\`: marketing for any product: posts, demo videos and screenshots, launches, landing-page copy, campaigns. It drafts them for the user to approve, and never posts.`] : []),
|
|
22
25
|
`- A repo, by its folder name, for work that belongs in that project${others.length ? `: ${others.map((l) => `\`${l}\``).join(", ")}` : ""}.`,
|
|
23
26
|
""];
|
|
24
27
|
}
|
|
@@ -69,6 +72,14 @@ const ACTS = /\b(?:send|sent|sending|post|posted|publish|delete|remove|pay|close
|
|
|
69
72
|
const CATCH = /\b(?:catch|before you|check|make sure|careful|fix (?:that|it)|first)\b/i;
|
|
70
73
|
const NOT_THEIRS = /\bconnectors?\b|\bclaude\.ai\b/i; // a connector to sign in again shows on Home already
|
|
71
74
|
const SENDS = /draft|reply|email|mail|message|post/i;
|
|
75
|
+
// "when / if / once / until …" before it: it says how something behaves, not that
|
|
76
|
+
// something waits on the user now ("It's lit amber when something needs you: … work
|
|
77
|
+
// waiting for your OK" described a feature, and showed as a step only you could do).
|
|
78
|
+
const COND = /\b(?:when|whenever|if|once|until|unless)\b/i;
|
|
79
|
+
function asksNow(s) {
|
|
80
|
+
for (const re of [HELD, YOURS]) { const m = re.exec(s); if (m) { const c = COND.exec(s); if (!c || c.index > m.index) return true; } }
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
72
83
|
function leftToYou(text) {
|
|
73
84
|
const points = String(text || "").replace(/```[\s\S]*?```/g, (m) => m.replace(/\s+/g, " ")).split(/\n+|\s+-\s+(?=\*\*)/).map((p) => p.replace(/^\s*[-*•]\s+/, "").trim()).filter(Boolean)
|
|
74
85
|
.map((p) => { const lm = p.match(/^\*\*([^*]{2,60}?):?\*\*:?\s*/), body = lm ? p.slice(lm[0].length) : p;
|
|
@@ -76,7 +87,7 @@ function leftToYou(text) {
|
|
|
76
87
|
const clean = (x, n) => { x = String(x || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); return x.length > n ? x.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : x; };
|
|
77
88
|
for (let k = 0; k < points.length; k++) {
|
|
78
89
|
const { label, ss, off } = points[k]; if (off) continue;
|
|
79
|
-
const i = ss.findIndex((s) => !DID.test(s) && (
|
|
90
|
+
const i = ss.findIndex((s) => !DID.test(s) && asksNow(s));
|
|
80
91
|
if (i < 0) continue;
|
|
81
92
|
const s = ss[i], next = points[k + 1] && !points[k + 1].off ? points[k + 1].ss : [];
|
|
82
93
|
// what to check first: the catch it named (in this point, then the next), else what it says next
|
|
@@ -104,4 +115,4 @@ function parseHandoffs(md) {
|
|
|
104
115
|
return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
|
|
118
|
+
export { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
|
package/home.mjs
CHANGED
|
@@ -11,42 +11,28 @@
|
|
|
11
11
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { watchBoard } from "./watch.mjs";
|
|
16
|
-
import { pendingReview, pushTasks } from "./tasks.mjs";
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { watchBoard, draftReply } from "./watch.mjs";
|
|
16
|
+
import { pendingReview, pushTasks, addTask } from "./tasks.mjs";
|
|
17
17
|
import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
|
|
18
18
|
import { linksState } from "./links.mjs";
|
|
19
19
|
import { knowledgeState } from "./knowledge.mjs";
|
|
20
|
-
import { CONFIG_DIR, loadTasks } from "./core.mjs";
|
|
20
|
+
import { CONFIG_DIR, loadTasks, saveTasks } from "./core.mjs";
|
|
21
21
|
import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
|
|
22
|
-
import { converse, actIn, actNow, taskIn } from "./mind.mjs";
|
|
23
|
-
import { repoPathMap } from "./scan.mjs";
|
|
22
|
+
import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
|
|
23
|
+
import { repoPathMap, laneMap } from "./scan.mjs";
|
|
24
24
|
import { resolveProvider } from "./ai.mjs";
|
|
25
|
-
import { reportsNews } from "./reports.mjs";
|
|
25
|
+
import { reportsNews, listReports } from "./reports.mjs";
|
|
26
26
|
import { awaitingState, inboxSight } from "./handback.mjs";
|
|
27
27
|
import { newClashes } from "./checks.mjs";
|
|
28
28
|
import { postsState } from "./post.mjs";
|
|
29
|
+
import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles } from "./marketing.mjs";
|
|
29
30
|
|
|
30
31
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
31
32
|
let cached = null;
|
|
32
33
|
const RUNS = join(CONFIG_DIR, "drafts"); // an ops run's folder (mind.mjs actNow): its own lane, not a repo
|
|
33
34
|
const RUNS_LANE = "ops"; // what the work scene calls them all, together
|
|
34
35
|
|
|
35
|
-
// A project's name as people say it: the folder can lag behind (CallForge AI is
|
|
36
|
-
// Dailify). The README's title when it's a short name, else package.json's name
|
|
37
|
-
// when it's a word, else the folder's. Read once per folder.
|
|
38
|
-
const NAMES = new Map();
|
|
39
|
-
function displayName(path, folder) {
|
|
40
|
-
folder = String(folder || ""); if (!path) return folder;
|
|
41
|
-
if (NAMES.has(path)) return NAMES.get(path);
|
|
42
|
-
const read = (f) => { try { return readFileSync(join(path, f), "utf8"); } catch { return ""; } };
|
|
43
|
-
const plainName = (s) => { s = String(s || "").replace(/[*`[\]]|^_+|_+$/g, "").trim(); return s && s.length <= 32 && s.split(/\s+/).length <= 4 && !/[:—–|]|\s-\s/.test(s) ? s : ""; };
|
|
44
|
-
const h1 = plainName((read("README.md").match(/^#\s+(.+?)\s*#*\s*$/m) || [])[1]);
|
|
45
|
-
let pkg = ""; try { pkg = String(JSON.parse(read("package.json")).name || "").replace(/^@[^/]+\//, ""); } catch {}
|
|
46
|
-
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;
|
|
47
|
-
NAMES.set(path, name);
|
|
48
|
-
return name;
|
|
49
|
-
}
|
|
50
36
|
// The lane a folder is: the repo it is (by the scan's map), or an ops run.
|
|
51
37
|
const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
|
|
52
38
|
|
|
@@ -85,6 +71,165 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
|
|
|
85
71
|
}
|
|
86
72
|
return out;
|
|
87
73
|
}
|
|
74
|
+
|
|
75
|
+
// ---- next up: what can be done when nothing waits on you ----------------------------
|
|
76
|
+
// With "Only the user can do" empty, Home suggests a few things that CAN be done next,
|
|
77
|
+
// each with its gain spelled out and one tap that starts an agent or makes a task
|
|
78
|
+
// (homeNext), from what Symbiot already knows: unread reports, a task with no project,
|
|
79
|
+
// a handover that errored and was left, a project's tasks waiting days for its agent,
|
|
80
|
+
// new mail or chats with no reply, and a decision you made that no task carries yet.
|
|
81
|
+
// One you took stays away a week (NEXT_FILE); the app lets you dismiss the box.
|
|
82
|
+
const NEXT_FILE = join(CONFIG_DIR, "home-next.json"), NEXT_MAX = 5, NEXT_KEEP = 7 * 86400000, DAY = 86400000;
|
|
83
|
+
const STALE_AFTER = 3 * DAY, RETRY_WITHIN = 14 * DAY, DECIDED_WITHIN = 14 * DAY;
|
|
84
|
+
function loadNext(file = NEXT_FILE) { try { const d = JSON.parse(readFileSync(file, "utf8")); return d && typeof d.taken === "object" && d.taken ? d : { taken: {} }; } catch { return { taken: {} }; } }
|
|
85
|
+
function saveNext(d, file = NEXT_FILE) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(file, JSON.stringify(d), { mode: 0o600 }); } catch {} }
|
|
86
|
+
// a remembered decision that asks for something to be done ("the user wants…", "decided to…"), not a fact about how things are
|
|
87
|
+
const WANTS = /\b(?:wants?|decided|asked for|plans? to|going to|agreed to)\b/i;
|
|
88
|
+
const words4 = (s) => new Set(String(s || "").toLowerCase().match(/[a-z0-9]{4,}/g) || []);
|
|
89
|
+
// The lane a task's words name (whatsapp_module for "…the WhatsApp module…"), or "".
|
|
90
|
+
function laneNamed(text, lanes) {
|
|
91
|
+
const t = " " + String(text || "").toLowerCase().replace(/[^a-z0-9]+/g, " ") + " ";
|
|
92
|
+
let best = "", len = 0;
|
|
93
|
+
for (const l of lanes) { const k = l.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); if (k.length >= 4 && k.length > len && t.includes(" " + k + " ")) { best = l; len = k.length; } }
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
96
|
+
const ago = (ms) => { const d = Math.floor(ms / DAY); return d >= 2 ? `${d} days` : d === 1 ? "a day" : "today"; };
|
|
97
|
+
// [{ id, title, gain, time, act: "agent"|"task"|"drafts"|"retry"|"go"|"assign", label }], best first.
|
|
98
|
+
function nextUp({ now = Date.now(), map = {}, list = [], named = displayName, deps = {} } = {}) {
|
|
99
|
+
const tryOr = (f, d) => { try { return f(); } catch { return d; } };
|
|
100
|
+
const taken = (deps.taken || (() => loadNext().taken))() || {};
|
|
101
|
+
const fresh = (id) => !(taken[id] && now - taken[id] < NEXT_KEEP);
|
|
102
|
+
const lanes = Object.keys(map), out = [];
|
|
103
|
+
const busy = new Set(list.filter((a) => a.status === "running").map((a) => laneOfPath(a.path, map)).filter(Boolean));
|
|
104
|
+
const parked = new Set(tryOr(deps.parked || parkedPaths, []) || []);
|
|
105
|
+
const allTasks = (tryOr(deps.tasks || loadTasks, []) || []).filter(Boolean), tasks = allTasks.filter((t) => !t.done && !t.archived && !t.review);
|
|
106
|
+
// unread reports: an agent reads them all and leaves one page, their next steps as ideas you add in a click
|
|
107
|
+
const rep = tryOr(deps.reports || reportsNews, { count: 0 }) || { count: 0 };
|
|
108
|
+
if (rep.count && fresh("next:reports")) out.push({ id: "next:reports", title: `Triage the ${rep.count} unread report${rep.count > 1 ? "s" : ""}`, gain: `clears the backlog: one page to read, and their next steps as tasks you add in a click`, time: `~${Math.max(5, Math.min(30, Math.ceil(rep.count * 1.25 / 5) * 5))} min, by an agent`, act: "agent", label: "Start an agent" });
|
|
109
|
+
// a handover that errored and was left (older than Home's asks): run it on its own
|
|
110
|
+
const retry = (tryOr(() => (deps.stuck || stuckHandovers)({ now, within: RETRY_WITHIN }), []) || []).filter((h) => fresh("next:retry:" + h.id));
|
|
111
|
+
for (const h of retry.slice(0, 1)) out.push({ id: "next:retry:" + h.id, title: `Retry ${h.from.lane}'s handover: ${plain(firstLine(h.text), 70)}`, gain: `it errored ${ago(now - (h.at || now))} ago (${plain(String(h.error || "").replace(/[.\s]+$/, ""), 60) || "it didn't start"}), and ${h.from.lane}'s agent carries on once it's done`, time: "runs by itself", act: "retry", label: "Retry it" });
|
|
112
|
+
// a project's tasks waiting days for its agent, nothing at work there
|
|
113
|
+
const byRepo = {};
|
|
114
|
+
for (const t of tasks) if (t.repo && map[t.repo] && !busy.has(t.repo) && !parked.has(map[t.repo])) (byRepo[t.repo] = byRepo[t.repo] || []).push(t);
|
|
115
|
+
const stale = Object.keys(byRepo).map((r) => ({ repo: r, n: byRepo[r].length, oldest: Math.min(...byRepo[r].map((t) => Number(t.ts) || now)) }))
|
|
116
|
+
.filter((g) => now - g.oldest > STALE_AFTER && fresh("next:go:" + g.repo)).sort((a, b) => a.oldest - b.oldest);
|
|
117
|
+
for (const g of stale.slice(0, 2)) out.push({ id: "next:go:" + g.repo, title: `Send ${named(map[g.repo], g.repo)}'s ${g.n} waiting task${g.n > 1 ? "s" : ""} to its agent`, gain: `the oldest has waited ${ago(now - g.oldest)}, and nothing is at work there`, time: "runs by itself", act: "go", label: "Start its agent" });
|
|
118
|
+
// a task with no project: the project its words name gets it, else an agent of its own
|
|
119
|
+
for (const t of tasks.filter((x) => !x.repo && fresh("next:float:" + x.id)).slice(0, 1)) {
|
|
120
|
+
const lane = laneNamed(t.text, lanes);
|
|
121
|
+
out.push({ id: "next:float:" + t.id, title: `${lane ? `Give ${named(map[lane], lane)}` : "Start"} "${plain(t.text, 60)}"`, gain: lane ? `it has no project, so no agent picks it up: ${lane}'s agent takes it now` : "it has no project, so no agent picks it up: one of its own starts on it", time: "runs by itself", act: lane ? "assign" : "agent", label: "Start an agent", ...(lane ? { repo: lane } : {}) });
|
|
122
|
+
}
|
|
123
|
+
// new mail or chats with no reply: your agent drafts them, unsent, for you to check
|
|
124
|
+
const cards = ((tryOr(deps.board || (() => watchBoard(24, now)), { cards: [] }) || {}).cards || []).filter((c) => c.count > 0 && /^(mail|chat|social)$/.test(c.source || "") && fresh("next:drafts:" + c.id));
|
|
125
|
+
for (const c of cards.slice(0, 1)) { const k = Math.min(3, c.count), where = plain(String(c.name || "").split(" - ")[0], 30);
|
|
126
|
+
out.push({ id: "next:drafts:" + c.id, title: c.count > k ? `Draft replies to the newest ${k} of the ${c.label} on ${where}` : `Draft replies to the ${c.label} on ${where}`, gain: "each waits as a draft, unsent, for you to check and send", time: `~${k * 3} min, by an agent`, act: "drafts", label: "Draft replies" }); }
|
|
127
|
+
// a decision you made lately (Symbiot's memory) that no open task carries yet
|
|
128
|
+
if (out.length < 3) {
|
|
129
|
+
const nodes = (tryOr(deps.mind || (() => loadMind().nodes), []) || []).filter((n) => n.kind === "decision");
|
|
130
|
+
const carried = allTasks.map((t) => words4(t.text)); // done or not: what a task already took on
|
|
131
|
+
const facts = nodes.flatMap((n) => (n.facts || []).map((f) => ({ n, f }))).filter(({ f }) => now - (f.ts || 0) < DECIDED_WITHIN).sort((a, b) => b.f.ts - a.f.ts);
|
|
132
|
+
for (const { n, f } of facts) {
|
|
133
|
+
const id = "next:decision:" + n.id + ":" + (f.ts || 0), w = words4(f.text);
|
|
134
|
+
if (!fresh(id) || !WANTS.test(f.text) || carried.some((c) => [...w].filter((x) => c.has(x)).length >= Math.max(3, w.size * 0.5))) continue;
|
|
135
|
+
const lane = laneNamed(n.name + " " + f.text, lanes);
|
|
136
|
+
out.push({ id, title: `Make a task of what you decided: ${plain(f.text, 70)}`, gain: `so it's done${lane ? ` in ${named(map[lane], lane)}` : ""}, not only remembered`, time: "a tap", act: "task", label: "Make a task", ...(lane ? { repo: lane } : {}), text: f.text });
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out.slice(0, NEXT_MAX);
|
|
141
|
+
}
|
|
142
|
+
// Take a suggestion (its id, from homeState's next): it starts its agent or makes
|
|
143
|
+
// its task, and stays away a week. { ok, said } or { error }.
|
|
144
|
+
function homeNext(id, deps = {}) {
|
|
145
|
+
id = String(id || ""); cached = null;
|
|
146
|
+
const h = deps.state || homeState({ fresh: true }), s = (h.next || []).find((x) => x.id === id);
|
|
147
|
+
if (!s) return { error: (h.you || []).length ? "Something needs you first: it's at the top of Home." : "That suggestion isn't here any more." };
|
|
148
|
+
const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
|
|
149
|
+
const run = deps.run || runHandoff, ref = id.split(":").slice(2).join(":");
|
|
150
|
+
let r;
|
|
151
|
+
if (s.act === "agent" && id === "next:reports") {
|
|
152
|
+
const reps = (deps.reportsList || listReports)({ map }).filter((x) => x.new).slice(0, 20);
|
|
153
|
+
if (!reps.length) return { error: "No unread reports left." };
|
|
154
|
+
r = (deps.act || actNow)(`Triage the user's ${reps.length} unread Symbiot reports, so they read one page instead of ${reps.length}. Read each:\n${reps.map((x) => `- ${x.title} (${x.lane || "ops"}): ${x.file}`).join("\n")}\n\nWrite \`.symbiot/REPORTS-TRIAGE.md\` (a "# " title first): per report, two lines at most on what it found and what's still open, the ones that need a decision first. Put each open next step under \`## Suggestions\` in \`.symbiot/QUESTIONS.md\`, starting with its project's folder name (\`- [repo: <lane>] …\`), so the user adds it as a task in a click. Change nothing else.`, { title: "Home: what's next" });
|
|
155
|
+
} else if (s.act === "agent") {
|
|
156
|
+
const t = (deps.tasks || loadTasks)().find((x) => x.id === ref); if (!t) return { error: "That task isn't on the list any more." };
|
|
157
|
+
r = (deps.act || actNow)(`${t.text}\n\n(This was on the user's task list with no project. If it belongs in one of their repos, hand it over to that lane.)`, { title: "Home: what's next" });
|
|
158
|
+
} else if (s.act === "assign") {
|
|
159
|
+
const all = (deps.tasks || loadTasks)(), t = all.find((x) => x.id === ref); if (!t) return { error: "That task isn't on the list any more." };
|
|
160
|
+
t.repo = s.repo; (deps.save || saveTasks)(all);
|
|
161
|
+
r = goLane(s.repo, map, { push: deps.push, run });
|
|
162
|
+
} else if (s.act === "go") r = goLane(ref, map, { push: deps.push, run });
|
|
163
|
+
else if (s.act === "retry") r = (deps.allow || allowHandover)(ref, {});
|
|
164
|
+
else if (s.act === "task") r = (deps.task || taskIn)(s.text, s.repo || "", { map });
|
|
165
|
+
else if (s.act === "drafts") {
|
|
166
|
+
const card = ((deps.board || (() => watchBoard(24)))().cards || []).find((c) => c.id === ref), items = ((card && card.items) || []).filter((n) => n.need).slice(0, 3);
|
|
167
|
+
if (!items.length) return { error: "Nothing there waits for a reply any more." };
|
|
168
|
+
const done = items.map((n) => (deps.draft || draftReply)(n.id, { run })), okN = done.filter((x) => x && x.ok).length;
|
|
169
|
+
r = okN ? { ok: true, n: okN } : done[0];
|
|
170
|
+
}
|
|
171
|
+
if (!r || r.error) return { error: (r && r.error) || "It didn't start. Check your agent in Settings." };
|
|
172
|
+
const d = loadNext(deps.file); d.taken[id] = Date.now(); for (const k of Object.keys(d.taken)) if (Date.now() - d.taken[k] > NEXT_KEEP) delete d.taken[k]; saveNext(d, deps.file);
|
|
173
|
+
const said = s.act === "task" ? `It's a task now${s.repo ? ` in ${s.repo}` : ""}.` : s.act === "drafts" ? `Your agent is drafting ${r.n} repl${r.n > 1 ? "ies" : "y"}. They wait, unsent, for you to check.` : s.act === "retry" ? "Started it as a run of its own. What it does goes back to the agent that asked." : r.queued ? "Its agent takes it once the run there now finishes." : "An agent started on it. It's on the Workdesk.";
|
|
174
|
+
return { ok: true, said };
|
|
175
|
+
}
|
|
176
|
+
// A lane's waiting tasks to its agent, the way Go does for one project.
|
|
177
|
+
function goLane(repo, map, { push = pushTasks, run = runHandoff } = {}) {
|
|
178
|
+
if (!map[repo]) return { error: `${repo} isn't one of your projects any more.` };
|
|
179
|
+
const p = (push || pushTasks)({ repo }); if (!p || !p.written || !p.written.length) return { error: `Nothing of ${repo}'s is waiting to start.` };
|
|
180
|
+
const e = (run || runHandoff)(map[repo]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
|
|
181
|
+
return e.parked ? { error: `${repo} is parked: unpark it to start its agent.` } : { ok: true, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Marketing: a lane of its own, across products (marketing.mjs) ----------------------
|
|
185
|
+
// What its page shows, and its orb on Home: what needs you there (its agent's questions,
|
|
186
|
+
// its work waiting for your OK, the week's post drafts waiting on you), its tasks with the
|
|
187
|
+
// product each markets, other lanes' tasks about marketing, and what its agent drafted.
|
|
188
|
+
// on: whether Home shows its orb (once posts can be drafted, or it has anything at all).
|
|
189
|
+
function marketingState({ deps = {}, list, pend, map, named = displayName } = {}) {
|
|
190
|
+
const tryOr = (f, d) => { try { return f(); } catch { return d; } };
|
|
191
|
+
map = map || tryOr(deps.repos || laneMap, {}) || {};
|
|
192
|
+
const dir = map[MARKETING] || MARKETING_DIR;
|
|
193
|
+
const posts = tryOr(deps.posts || postsState, null) || { posts: [], done: [] };
|
|
194
|
+
const tasks = (tryOr(deps.tasks || loadTasks, []) || []).filter((t) => t && !t.done && !t.archived);
|
|
195
|
+
// the products to tag by: your repos', the ones tasks were tagged with, and those its agent drafted for
|
|
196
|
+
const known = [...new Set([...productNames(map), ...tasks.map((t) => productOf(t.text)).filter(Boolean)])];
|
|
197
|
+
const files = tryOr(() => (deps.files || draftFiles)(dir, known), []) || [];
|
|
198
|
+
const names = [...new Set([...known, ...files.map((f) => f.product).filter(Boolean)])];
|
|
199
|
+
list = list || tryOr(deps.agents || agentsList, []) || [];
|
|
200
|
+
const run = list.find((a) => a.path === dir), qs = (run && run.ask && run.ask.questions) || [], working = !!(run && run.status === "running");
|
|
201
|
+
const needs = qs.map((q, i) => ({ kind: "ask", id: "ask:" + i, text: plain(q.q, 160), options: (q.options || []).slice(0, 2), product: productOf(q.q, names) }));
|
|
202
|
+
const ready = (pend || tryOr(deps.pending || pendingReview, []) || []).find((r) => r.repo === MARKETING && r.path && !r.running && ((r.tasks || []).length || (r.files || []).length));
|
|
203
|
+
if (ready) { const n = (ready.tasks || []).length, f = (ready.files || []).length; needs.push({ kind: "approve", id: "approve", text: `${n ? `${n} piece${n > 1 ? "s" : ""} of marketing done` : "Marketing's agent changed things without a task"}${f ? ` · ${f} file${f > 1 ? "s" : ""}` : ""}: only you decide`, count: n || f }); }
|
|
204
|
+
for (const p of (posts.posts || []).filter((x) => x.status === "waiting")) needs.push({ kind: "draft", id: "draft:" + p.id, text: plain(firstLine(p.text), 120), product: productOf(p.text, names) });
|
|
205
|
+
const lane = tasks.filter((t) => t.repo === MARKETING).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), status: t.review ? "review" : working ? "working" : "waiting", ts: t.ts || 0 }));
|
|
206
|
+
const elsewhere = tasks.filter((t) => t.repo !== MARKETING && MARKETING_WORDS.test(t.text)).slice(0, 12).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), repo: t.repo || "", name: t.repo ? named(map[t.repo], t.repo) : "" }));
|
|
207
|
+
const seen = [...new Set([...lane, ...elsewhere, ...needs, ...files].map((x) => x.product).filter(Boolean))];
|
|
208
|
+
const on = !!(posts.canDraft || (posts.posts || []).length || (posts.done || []).length || lane.length || elsewhere.length || files.length || needs.length || run);
|
|
209
|
+
return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at }) => ({ rel, name, product, at })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
|
|
210
|
+
}
|
|
211
|
+
// A task for Marketing, tagged with the product it markets (the page's Add). The task or { error }.
|
|
212
|
+
function marketingTask(text, product = "", { add = addTask } = {}) {
|
|
213
|
+
text = String(text || "").trim(); if (!text) return { error: "Say what the marketing task is." };
|
|
214
|
+
cached = null; return add(tagged(text, product), MARKETING);
|
|
215
|
+
}
|
|
216
|
+
// Another lane's task about marketing, moved to Marketing (and tagged): its agent takes it
|
|
217
|
+
// from then on. Not one waiting for your OK: approve or send it back where it is first.
|
|
218
|
+
function moveToMarketing(id, product = "", { load = loadTasks, save = saveTasks, map } = {}) {
|
|
219
|
+
const all = load(), t = all.find((x) => x.id === String(id || ""));
|
|
220
|
+
if (!t || t.done || t.archived) return { error: "That task isn't open any more." };
|
|
221
|
+
if (t.review) return { error: "It waits for your OK where it is: approve it or send it back first." };
|
|
222
|
+
if (product || !/^\s*\[/.test(t.text)) t.text = tagged(t.text, product || productOf(t.text, productNames(map || (() => { try { return laneMap(); } catch { return {}; } })())));
|
|
223
|
+
t.repo = MARKETING; save(all); cached = null;
|
|
224
|
+
return { ok: true, id: t.id };
|
|
225
|
+
}
|
|
226
|
+
// Start Marketing's agent on its waiting tasks (the page's Start its agent). { ok } or { error }.
|
|
227
|
+
function marketingGo(deps = {}) {
|
|
228
|
+
cached = null;
|
|
229
|
+
const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
|
|
230
|
+
return goLane(MARKETING, map, { push: deps.push, run: deps.run });
|
|
231
|
+
}
|
|
232
|
+
|
|
88
233
|
// { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
|
|
89
234
|
// lanes: [{ from, to, text, status, times }], working: n, at }
|
|
90
235
|
// An ask carries what its blob on home needs: the lane (repo, or "" for an ops
|
|
@@ -97,7 +242,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
97
242
|
const lanes = deps.lanes || (() => { try { return lanesState(); } catch { return { handoffs: [] }; } });
|
|
98
243
|
const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
|
|
99
244
|
const connected = deps.connected || (() => !!resolveProvider());
|
|
100
|
-
const repos = deps.repos || (() => { try { return
|
|
245
|
+
const repos = deps.repos || (() => { try { return laneMap(); } catch { return {}; } });
|
|
101
246
|
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
102
247
|
const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
|
|
103
248
|
// can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
|
|
@@ -108,8 +253,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
108
253
|
const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
|
|
109
254
|
const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
|
|
110
255
|
const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
|
|
111
|
-
// Marketing
|
|
112
|
-
const marketing = deps.marketing || (() => { try { const
|
|
256
|
+
// Marketing, a lane of its own across products: its orb, lit when something there needs you
|
|
257
|
+
const marketing = deps.marketing || ((o) => { try { const m = marketingState(o); return m.on ? { needs: m.needCount, working: m.working, waiting: m.waiting } : false; } catch { return false; } });
|
|
113
258
|
const named = deps.name || displayName;
|
|
114
259
|
const you = [], map = repos() || {};
|
|
115
260
|
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
@@ -118,11 +263,12 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
118
263
|
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" });
|
|
119
264
|
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" });
|
|
120
265
|
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" });
|
|
121
|
-
if (!Object.keys(map).
|
|
266
|
+
if (!Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
|
|
122
267
|
// no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
|
|
123
268
|
// Home didn't say "All handled" truthfully. One click for the one on this computer.
|
|
124
269
|
if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
|
|
125
|
-
|
|
270
|
+
const pend = pending();
|
|
271
|
+
for (const r of pend) {
|
|
126
272
|
if (!r.path || r.running) continue;
|
|
127
273
|
const n = (r.tasks || []).length, files = (r.files || []).length;
|
|
128
274
|
if (!n && !files) continue;
|
|
@@ -149,7 +295,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
149
295
|
? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
|
|
150
296
|
: { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
|
|
151
297
|
}
|
|
152
|
-
const
|
|
298
|
+
const bd = board() || {}, feeds = (bd.cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
|
|
153
299
|
// a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
|
|
154
300
|
const clash = clashes();
|
|
155
301
|
if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
|
|
@@ -157,7 +303,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
157
303
|
if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
|
|
158
304
|
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 } : {}) }));
|
|
159
305
|
const working = list.filter((a) => a.status === "running").length;
|
|
160
|
-
|
|
306
|
+
// nothing waits on you: a few things that can be done next (none while anything does)
|
|
307
|
+
const next = you.length ? [] : nextUp({ now, map, list, named, deps: { board: () => bd, reports: () => rep, ...(deps.next || {}) } });
|
|
308
|
+
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, next, marketing: marketing({ list, map, named, pend }), at: now };
|
|
161
309
|
if (!deps.board) cached = out;
|
|
162
310
|
return out;
|
|
163
311
|
}
|
|
@@ -203,7 +351,7 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
|
203
351
|
if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
|
|
204
352
|
return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
|
|
205
353
|
}
|
|
206
|
-
return { error: "Answer that one on its block
|
|
354
|
+
return { error: "Answer that one on its block on the Workdesk." };
|
|
207
355
|
}
|
|
208
356
|
|
|
209
357
|
// Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
|
|
@@ -243,6 +391,8 @@ function homeContext(h) {
|
|
|
243
391
|
`Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
|
|
244
392
|
`New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
|
|
245
393
|
`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"]),
|
|
394
|
+
...(h.marketing ? [`Marketing (a lane of its own, for marketing across all their products; "repo": "${MARKETING}"): ${h.marketing.needs ? `${h.marketing.needs} thing${h.marketing.needs > 1 ? "s" : ""} there need${h.marketing.needs > 1 ? "" : "s"} them` : "nothing needs them"}${h.marketing.working ? ", its agent is at work" : ""}.`] : []),
|
|
395
|
+
...((h.next || []).length ? ["Could be done next (Home suggests these, one tap each):", ...h.next.map((s) => `- ${s.title}: ${s.gain} (${s.time})`)] : []),
|
|
246
396
|
].join("\n");
|
|
247
397
|
}
|
|
248
398
|
|
|
@@ -256,7 +406,7 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
|
|
|
256
406
|
const shotNote = pics.length ? `\n\nScreenshots the user attached (open them with your Read tool): ${pics.map((i) => i.path).join(", ")}` : "";
|
|
257
407
|
if (!ask && !resolveProvider()) return { error: "not-connected" };
|
|
258
408
|
const h = state || homeState({ now });
|
|
259
|
-
let map = {}; try { map =
|
|
409
|
+
let map = {}; try { map = laneMap(); } catch {}
|
|
260
410
|
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.";
|
|
261
411
|
const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data }) => ({ mime, data })), ...(ask ? { ask } : {}),
|
|
262
412
|
act: {
|
|
@@ -280,7 +430,7 @@ function workScene({ deps = {} } = {}) {
|
|
|
280
430
|
const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
|
|
281
431
|
const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
|
|
282
432
|
const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
|
|
283
|
-
const map = (deps.repos || (() => { try { return
|
|
433
|
+
const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
|
|
284
434
|
const named = deps.name || displayName;
|
|
285
435
|
const parked = new Set((deps.parked || (() => { try { return parkedPaths(); } catch { return []; } }))());
|
|
286
436
|
const isParked = (repo) => !!map[repo] && parked.has(map[repo]);
|
|
@@ -343,4 +493,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
|
|
|
343
493
|
return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
|
|
344
494
|
}
|
|
345
495
|
|
|
346
|
-
export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
|
|
496
|
+
export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps };
|
package/index.mjs
CHANGED
|
@@ -329,16 +329,16 @@ async function viaApp(path, body) {
|
|
|
329
329
|
} catch (e) { return { error: "Lost Symbiot's app mid-way: " + ((e && e.message) || e) }; }
|
|
330
330
|
}
|
|
331
331
|
// symbiot away: Symbiot full screen on every screen (again: closes it). --shortcut:
|
|
332
|
-
// Super
|
|
332
|
+
// Super+` runs it (on COSMIC it's set up; elsewhere it says how).
|
|
333
333
|
async function cmdAway() {
|
|
334
334
|
if (has("shortcut")) {
|
|
335
335
|
const { installShortcut } = await import("./away.mjs");
|
|
336
336
|
const r = installShortcut({ cmd: `${process.execPath} ${process.argv[1]} away` });
|
|
337
337
|
if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
|
|
338
|
-
console.log(r.manual ? r.note : `${c.g("✓")} Super
|
|
338
|
+
console.log(r.manual ? r.note : `${c.g("✓")} Super+\` (the key above Tab) now opens Away. ${c.d(r.file)}`);
|
|
339
339
|
return;
|
|
340
340
|
}
|
|
341
|
-
const r = await viaApp("/api/away", {});
|
|
341
|
+
const r = await viaApp("/api/away", { open: "toggle" });
|
|
342
342
|
if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Start it with symbiot app, then Away works.")); process.exitCode = 1; return; }
|
|
343
343
|
if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
|
|
344
344
|
}
|
|
@@ -768,7 +768,7 @@ ${c.b("Usage")}
|
|
|
768
768
|
symbiot todo what's still on your plate
|
|
769
769
|
symbiot app open the visual app in your browser
|
|
770
770
|
symbiot away [--shortcut] Symbiot full screen on every screen while you're away;
|
|
771
|
-
again closes it. --shortcut: Super
|
|
771
|
+
again closes it. --shortcut: Super+\` does it
|
|
772
772
|
symbiot app --fresh [--keep] try it as someone new: a brand-new Symbiot in
|
|
773
773
|
a throwaway home, none of your data or accounts
|
|
774
774
|
symbiot drift [--fetch] what's out of sync / at risk across repos
|
package/lanes.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
|
24
24
|
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
|
|
25
25
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
26
26
|
import { actNow } from "./mind.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { laneMap } from "./scan.mjs";
|
|
28
28
|
import { parseRun, lastRunText, readRunLog } from "./work.mjs";
|
|
29
29
|
import { OPS, parseHandoffs } from "./handover.mjs";
|
|
30
30
|
|
|
@@ -53,7 +53,7 @@ function laneTarget(lane, map) {
|
|
|
53
53
|
|
|
54
54
|
// Pick up what a folder's agent handed over and start the lanes it named.
|
|
55
55
|
// Each handover is started once (its key: where from, which lane, what).
|
|
56
|
-
function dispatch(path, { map =
|
|
56
|
+
function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
|
|
57
57
|
const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
|
|
58
58
|
const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
|
|
59
59
|
const started = [];
|
|
@@ -151,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
|
|
|
151
151
|
|
|
152
152
|
// One pass: start what's been handed over, from every lane, then report back
|
|
153
153
|
// what's done. The app runs it every 20 seconds.
|
|
154
|
-
function lanesTick({ map =
|
|
154
|
+
function lanesTick({ map = laneMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
|
|
155
155
|
const ledger = loadLedger();
|
|
156
156
|
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
157
157
|
const started = [];
|
|
@@ -208,10 +208,10 @@ function pastIt(e, hs) {
|
|
|
208
208
|
}
|
|
209
209
|
// The errored handovers that still wait on you, newest first:
|
|
210
210
|
// [{ id, from, text, error, at, dirs, q, options }].
|
|
211
|
-
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
211
|
+
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs, within = STUCK_FOR } = {}) {
|
|
212
212
|
const hs = ledger.handoffs, out = [];
|
|
213
213
|
hs.forEach((e) => {
|
|
214
|
-
if (e.status !== "error" || e.skipped || now - (e.at || 0) >
|
|
214
|
+
if (e.status !== "error" || e.skipped || now - (e.at || 0) > within) return;
|
|
215
215
|
if (pastIt(e, hs)) return;
|
|
216
216
|
const dirs = dirsOf(e.text, e.from.path);
|
|
217
217
|
out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
|
|
@@ -286,7 +286,7 @@ async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
|
|
|
286
286
|
if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
|
|
287
287
|
cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
|
|
288
288
|
let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
|
|
289
|
-
const moves = await orcaMoves(repos, map ||
|
|
289
|
+
const moves = await orcaMoves(repos, map || laneMap(), { remoteOf });
|
|
290
290
|
for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
|
|
291
291
|
return { moves };
|
|
292
292
|
}
|