symbiot 0.54.3 → 0.54.4
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 +12 -0
- package/README.md +15 -7
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/handover.mjs +41 -2
- package/home.mjs +48 -14
- package/index.mjs +1 -1
- package/package.json +2 -2
- package/post.mjs +68 -10
- package/server.mjs +12 -4
- package/ui.mjs +112 -42
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.54.4 — 2026-10-07
|
|
6
|
+
|
|
7
|
+
- Removed the "Add a task" row from Tasks, since you add tasks through the bottom chat bar, and moved Send to repos beside Close.
|
|
8
|
+
- Fixed agent runs that wait on you, such as unsent drafts or steps only you can take, so they now appear on Home with what to check first.
|
|
9
|
+
- Moved the Reports Refresh button so it sits beside Close instead of under it.
|
|
10
|
+
- Added a Marketing section with post drafts, replies marked "maybe a customer", picture tools and the 4-week table, moved off the Dashboard.
|
|
11
|
+
- Reviewed where sandboxing for agent runs stands, with no changes to the app.
|
|
12
|
+
- Added marks on Connections showing which sites your agents can also use, visible before you link them.
|
|
13
|
+
- Changed the Dashboard to lead with what you watch; posts now appear under Marketing once drafting is possible.
|
|
14
|
+
- Fixed Home's right-side orbs overlapping zone names on screens 1366–1440 pixels wide.
|
|
15
|
+
- Added a "Pick your agent" step on first run that sets up the detected agent, such as Claude Code, in one click.
|
|
16
|
+
|
|
5
17
|
## 0.54.3 — 2026-10-07
|
|
6
18
|
|
|
7
19
|
- Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
|
package/README.md
CHANGED
|
@@ -93,8 +93,10 @@ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of
|
|
|
93
93
|
a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
|
|
94
94
|
- **Week / Standup / Todo** — the write-ups (these use your chosen AI), set as a
|
|
95
95
|
readable page with its dates; **Copy** copies the text as written.
|
|
96
|
-
- **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …).
|
|
97
|
-
|
|
96
|
+
- **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). You add
|
|
97
|
+
a task by telling Symbiot in the bar at the bottom: it files it under the right
|
|
98
|
+
project. Filter by type or repo, then **Send to repos** (at the top, beside
|
|
99
|
+
Close) to hand just those to your agent (see
|
|
98
100
|
[`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
|
|
99
101
|
finishes lands in **Awaiting your review** (see
|
|
100
102
|
[Review and approve](#review-and-approve-the-agents-work)). A task you tick
|
|
@@ -685,14 +687,15 @@ release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scan
|
|
|
685
687
|
and nothing else. **It never posts by itself**, and it doesn't schedule.
|
|
686
688
|
|
|
687
689
|
- **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
|
|
688
|
-
line of `---` between each. Or link LinkedIn (above
|
|
689
|
-
|
|
690
|
+
line of `---` between each. Or link LinkedIn (above, or **Link LinkedIn** under
|
|
691
|
+
**Marketing**) and click **Fill from LinkedIn** there (`symbiot post voice --linkedin`), which reads your
|
|
690
692
|
recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
|
|
691
693
|
- **Only what git shows.** Your AI gets the week as numbered facts and must say
|
|
692
694
|
which ones each post uses. A post that names a number, version, name or user
|
|
693
695
|
count the facts don't show is sent back once, then dropped. When every draft is
|
|
694
696
|
dropped, Symbiot tries once more from the start by itself before giving up.
|
|
695
|
-
- **You approve each one.** The drafts wait
|
|
697
|
+
- **You approve each one.** The drafts wait under **Marketing**, a section of its
|
|
698
|
+
own (its orb shows on Home once posts can be drafted), with **Approve**,
|
|
696
699
|
**Edit** and **Skip**. Approve copies the post to your clipboard and opens
|
|
697
700
|
LinkedIn's share box: you paste it and post it yourself.
|
|
698
701
|
- **Pictures and videos.** Each draft says in a line what picture or short video
|
|
@@ -709,10 +712,15 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
|
|
|
709
712
|
- **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
|
|
710
713
|
edit, approval and skip, with its text, date and platform.
|
|
711
714
|
- **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
|
|
712
|
-
show on its Dashboard card, marked **maybe a customer**
|
|
713
|
-
install it, what it costs or about team use. **Draft a reply** has your agent
|
|
715
|
+
show under **Marketing** (and on its Dashboard card), marked **maybe a customer**
|
|
716
|
+
when they ask how to install it, what it costs or about team use. **Draft a reply** has your agent
|
|
714
717
|
type a reply into the comment box without posting it (it can't press Post,
|
|
715
718
|
Comment, Reply or Send), and write it in its brief for you to paste.
|
|
719
|
+
- **The 4-week test.** Marketing keeps a row a week: posts published (and how
|
|
720
|
+
many had a picture), replies, profile visits (LinkedIn's own count), installs
|
|
721
|
+
(npm downloads of the packages your posts are about) and pricing questions.
|
|
722
|
+
Week 1 is the first week a post goes out; to move it, put
|
|
723
|
+
`{ "start": "2026-10-13" }` in `~/.config/symbiot/post-test.json`.
|
|
716
724
|
|
|
717
725
|
```bash
|
|
718
726
|
symbiot post # draft this week's 3 posts
|
package/adapt.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const MIN_EVENTS = 12; // under this many uses, adaptation fad
|
|
|
48
48
|
const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
|
|
49
49
|
const HYSTERESIS = 0.15;
|
|
50
50
|
const FITTS = { a: 0.1, b: 0.15 }; // seconds; only ratios matter here
|
|
51
|
-
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports"];
|
|
51
|
+
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports", "marketing"];
|
|
52
52
|
|
|
53
53
|
function loadAdapt(file = ADAPT_FILE) {
|
|
54
54
|
try { const d = JSON.parse(readFileSync(file, "utf8")); return { shapes: d.shapes || {}, trans: d.trans || {}, hours: d.hours || {}, modes: d.modes || {}, slots: d.slots || {}, shown: d.shown || null, events: d.events || 0 }; }
|
package/agents.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync
|
|
|
9
9
|
import { randomBytes, createHash } from "node:crypto";
|
|
10
10
|
import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
11
11
|
import { parseRun, lastRunText, readRunLog } from "./work.mjs";
|
|
12
|
-
import { parseFacts } from "./handover.mjs";
|
|
12
|
+
import { parseFacts, leftToYou } from "./handover.mjs";
|
|
13
13
|
import { scanRoots, repoPathMap } from "./scan.mjs";
|
|
14
14
|
import { knowledgeFolders } from "./knowledge.mjs";
|
|
15
15
|
|
|
@@ -141,6 +141,17 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
|
|
|
141
141
|
// For Settings → Handoff: the connectors, whether this command's runs get them, and
|
|
142
142
|
// the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
|
|
143
143
|
function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
|
|
144
|
+
// Connections, before you link: which sites agent runs can use too. A link signs
|
|
145
|
+
// Symbiot's browser in; a run reaches a site only through Claude's own connector for
|
|
146
|
+
// it (LINK_CONNECTOR), and only with Claude Code as your agent (or, with none picked
|
|
147
|
+
// yet, on this computer to pick). { claude, sites: { id: { name, connector, ready } } }:
|
|
148
|
+
// connector "" while you haven't connected it in claude.ai.
|
|
149
|
+
const claudeAgent = () => { const c = handoffCmd(); return isClaudeCmd(c) || (!c && hasCmd("claude")); };
|
|
150
|
+
function linkReach(conns = claudeConnectors(), claude = claudeAgent()) {
|
|
151
|
+
const sites = {};
|
|
152
|
+
for (const [id, [name, re]] of Object.entries(LINK_CONNECTOR)) { const c = conns.find((x) => re.test(x.name)); sites[id] = { name, connector: c ? c.name : "", ready: !!c && c.ready }; }
|
|
153
|
+
return { claude, sites };
|
|
154
|
+
}
|
|
144
155
|
const fillHandoff = (tmpl, repoPath, prompt = HANDOFF_PROMPT) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(prompt));
|
|
145
156
|
// ---- agents work on their own --------------------------------------------------------
|
|
146
157
|
// Talk to an agent and it just does the work: permission checks skipped, one
|
|
@@ -271,14 +282,54 @@ function earlierRuns() {
|
|
|
271
282
|
for (const r of loadRuns()) {
|
|
272
283
|
if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
|
|
273
284
|
const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
|
|
274
|
-
if (!busy && !ask.questions.length && !wait && !facts.length) continue;
|
|
275
285
|
const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
|
|
276
|
-
let
|
|
277
|
-
|
|
286
|
+
let end = Date.now(); if (!busy) try { end = statSync(log).mtimeMs; } catch {}
|
|
287
|
+
const needs = busy ? null : needsOf(r.path, { final: lastFinal(r.path), waiting: wait, end });
|
|
288
|
+
if (!busy && !ask.questions.length && !wait && !facts.length && !needs) continue;
|
|
289
|
+
let tail = "", work = null, progress = null; try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); } catch {}
|
|
290
|
+
out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
|
|
278
291
|
}
|
|
279
292
|
return out;
|
|
280
293
|
}
|
|
281
294
|
|
|
295
|
+
// ---- what a finished run left to you ----------------------------------------
|
|
296
|
+
// A run that ended saying something waits on you (handover.mjs leftToYou: "the
|
|
297
|
+
// draft isn't sent", "needs sudo, and only you can do that"), or waiting on a step
|
|
298
|
+
// an answer of yours picked (waitingFor), needs you, and says so: on its block in
|
|
299
|
+
// Agents and on Home, with what to check first. It did end, so before this nothing
|
|
300
|
+
// showed it: Home said nothing needed you while a reply to Cale sat in Drafts. It
|
|
301
|
+
// stays for NEEDS_FOR, until you go ahead or skip it (settled.json keeps which, per
|
|
302
|
+
// folder), a newer run there says something else, or a newer ops run took up the
|
|
303
|
+
// same task. { kind: "approve"|"step", what, check, label, key, waiting? } or null.
|
|
304
|
+
const NEEDS_FOR = 3 * 86400000, SETTLED = join(CONFIG_DIR, "settled.json"), ACT_DIR = join(CONFIG_DIR, "drafts") + "/";
|
|
305
|
+
const loadSettled = () => { try { const o = JSON.parse(readFileSync(SETTLED, "utf8")); return o && typeof o === "object" && !Array.isArray(o) ? o : {}; } catch { return {}; } };
|
|
306
|
+
function settleNeeds(path, key) {
|
|
307
|
+
const o = loadSettled(); o[path] = String(key || "");
|
|
308
|
+
try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(SETTLED, JSON.stringify(o, null, 2), { mode: 0o600 }); } catch {}
|
|
309
|
+
}
|
|
310
|
+
// A streaming run's last words (its final answer), read once per change of its log.
|
|
311
|
+
const FINALS = new Map();
|
|
312
|
+
function lastFinal(path) {
|
|
313
|
+
const log = join(path, ".symbiot", "agent.log"); let m = 0; try { m = statSync(log).mtimeMs; } catch { return ""; }
|
|
314
|
+
const c = FINALS.get(path); if (c && c.m === m) return c.final;
|
|
315
|
+
let final = ""; try { const w = parseRun(lastRunText(readRunLog(log))); final = w.stream ? w.final : ""; } catch {}
|
|
316
|
+
FINALS.set(path, { m, final }); return final;
|
|
317
|
+
}
|
|
318
|
+
const firstTask = (path) => (readSymbiot(path, "TASKS.md").match(/^\s*-\s*\[[ x]\]\s*(.+)$/im) || [])[1] || "";
|
|
319
|
+
// an ops run (a folder of its own each time) whose task a newer ops run took up
|
|
320
|
+
function takenUp(path, end) {
|
|
321
|
+
if (!path.startsWith(ACT_DIR)) return false;
|
|
322
|
+
const t = firstTask(path); return !!t && loadRuns().some((r) => r.path !== path && r.path.startsWith(ACT_DIR) && Number(r.startedAt) > end && sameTask(firstTask(r.path), t));
|
|
323
|
+
}
|
|
324
|
+
function needsOf(path, { final = "", waiting = null, end = Date.now(), now = Date.now() } = {}) {
|
|
325
|
+
if (now - end > NEEDS_FOR) return null;
|
|
326
|
+
const n = waiting ? { kind: "step", what: clipWords(String(waiting.step || "").replace(/^\s*👤\s*(You\s*(\([^)]*\))?:)?\s*/, "").trim(), 240), check: "", label: "", waiting: true } : leftToYou(final);
|
|
327
|
+
if (!n || !n.what) return null;
|
|
328
|
+
const key = digest(n.what);
|
|
329
|
+
if (loadSettled()[path] === key || takenUp(path, end)) return null;
|
|
330
|
+
return { ...n, key };
|
|
331
|
+
}
|
|
332
|
+
|
|
282
333
|
// ---- a step that's yours: wait for it -----------------------------------------
|
|
283
334
|
// An answer that picks a "👤 You:" option leaves a step for the user to do (put a
|
|
284
335
|
// key in .env, allow a command in Settings). Starting the agent before it's done
|
|
@@ -484,6 +535,9 @@ const AGENT_LIST = [
|
|
|
484
535
|
["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
|
|
485
536
|
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
486
537
|
];
|
|
538
|
+
// The agent to offer a new user in one click (Home's "Pick your agent"): the first
|
|
539
|
+
// coding agent on this computer, in AGENT_LIST's order (Claude Code first). null if none.
|
|
540
|
+
function pickAgent() { const a = AGENT_LIST.find(([cmd]) => hasCmd(cmd)); return a ? { label: a[1], name: a[1].split(" — ")[0], tmpl: a[2] } : null; }
|
|
487
541
|
function detectHandoffs() {
|
|
488
542
|
const editors = [];
|
|
489
543
|
for (const [cmd, label, app] of IDE_LIST) {
|
|
@@ -885,8 +939,10 @@ function agentsList() {
|
|
|
885
939
|
const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
|
|
886
940
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
887
941
|
const facts = first && e.status !== "running" ? factsOf(e.path) : [];
|
|
888
|
-
|
|
942
|
+
const waiting = first && e.status !== "running" ? waitingFor(e.path) : null;
|
|
943
|
+
const needs = first && e.status !== "running" ? needsOf(e.path, { final: work ? tail : "", waiting, end: e.endedAt || Date.now() }) : null;
|
|
944
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
889
945
|
}).concat(earlierRuns());
|
|
890
946
|
}
|
|
891
947
|
|
|
892
|
-
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
|
948
|
+
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
package/handover.mjs
CHANGED
|
@@ -24,7 +24,7 @@ function handoverRules(lanes = [], here = "") {
|
|
|
24
24
|
}
|
|
25
25
|
// The rule for options in QUESTIONS.md: the user gets only what no agent can do,
|
|
26
26
|
// and the option says which kind it is.
|
|
27
|
-
const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it.";
|
|
27
|
+
const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
|
|
28
28
|
|
|
29
29
|
// What a run hands back to Symbiot itself (handback.mjs reads it): facts for
|
|
30
30
|
// memory, and emails that wait on a reply, which Symbiot then watches for; and
|
|
@@ -53,6 +53,45 @@ function parseAwaiting(text) {
|
|
|
53
53
|
})).filter((x) => x.subject).slice(0, 20);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// ---- what a run's last words leave to the user ----------------------------------
|
|
57
|
+
// A run that ends saying something waits on the user ("the draft isn't sent",
|
|
58
|
+
// "only you can do that", "needs sudo") without asking it in QUESTIONS.md left it
|
|
59
|
+
// where nothing showed it: the email to Jono sat in Drafts and nothing asked for
|
|
60
|
+
// the OK. leftToYou finds it: { kind, what, check, label } or null.
|
|
61
|
+
// kind "approve": what an agent does once the user says go (send, post, delete, pay,
|
|
62
|
+
// close); "step": what only the user can do (sudo, a password, signing in). what:
|
|
63
|
+
// the sentence that says so; check: what to check first (the catch it named, else
|
|
64
|
+
// the point's next sentence); label: the point's bold name ("Reply to Cale").
|
|
65
|
+
const HELD = /\b(?:isn['’]t|wasn['’]t|not|never|hasn['’]t been|haven['’]t|didn['’]t) (?:yet )?(?:been )?(?:sent|send it|posted|post it)\b|\bunsent\b|\bbefore you (?:send|post)\b|\bfor you to (?:send|post)\b|\b(?:send|sending|post|posting) it yourself\b|\byou(?:['’]ll| will) (?:send|post) it\b|\bwaits? (?:on|for) your (?:ok|okay|go-ahead|approval|say-so)\b/i;
|
|
66
|
+
const YOURS = /\bonly you (?:can|could|do)\b|\b(?:needs?|waits? (?:on|for)|waiting (?:on|for)) (?:your (?!review)\w+|you to)\b|\btype your password\b|\bneeds sudo\b/i;
|
|
67
|
+
const DID = /\b(?:I|we)(?:['’]ve| have)? (?:sent|posted|deleted|paid|closed)\b|\balready (?:went out|sent|posted)\b|\bnothing (?:was |is )?(?:sent|posted)\b/i;
|
|
68
|
+
const ACTS = /\b(?:send|sent|sending|post|posted|publish|delete|remove|pay|close|cancel)\b/i;
|
|
69
|
+
const CATCH = /\b(?:catch|before you|check|make sure|careful|fix (?:that|it)|first)\b/i;
|
|
70
|
+
const NOT_THEIRS = /\bconnectors?\b|\bclaude\.ai\b/i; // a connector to sign in again shows on Home already
|
|
71
|
+
const SENDS = /draft|reply|email|mail|message|post/i;
|
|
72
|
+
function leftToYou(text) {
|
|
73
|
+
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
|
+
.map((p) => { const lm = p.match(/^\*\*([^*]{2,60}?):?\*\*:?\s*/), body = lm ? p.slice(lm[0].length) : p;
|
|
75
|
+
return { label: lm ? lm[1].trim() : "", ss: body.split(/(?<=[.!?:])\s+(?=[A-Z`"*(])/).map((s) => s.trim()).filter(Boolean), off: NOT_THEIRS.test(p) }; });
|
|
76
|
+
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
|
+
for (let k = 0; k < points.length; k++) {
|
|
78
|
+
const { label, ss, off } = points[k]; if (off) continue;
|
|
79
|
+
const i = ss.findIndex((s) => !DID.test(s) && (HELD.test(s) || YOURS.test(s)));
|
|
80
|
+
if (i < 0) continue;
|
|
81
|
+
const s = ss[i], next = points[k + 1] && !points[k + 1].off ? points[k + 1].ss : [];
|
|
82
|
+
// what to check first: the catch it named (in this point, then the next), else what it says next
|
|
83
|
+
const catches = ss.filter((x, j) => j !== i && CATCH.test(x) && !DID.test(x));
|
|
84
|
+
const elsewhere = points.filter((p, j) => j !== k && !p.off).flatMap((p) => p.ss.filter((x) => /\byourself\b|\bbefore you\b|\bcatch\b/i.test(x) && !DID.test(x)).map((x) => (p.label ? `${p.label}: ${x}` : x)));
|
|
85
|
+
let check = (catches.length ? catches.slice(0, 2).join(" ") : elsewhere[0]) || ss[i + 1] || next[0] || "";
|
|
86
|
+
if (/:$/.test(check)) { const seq = [...ss.slice(i + 1), ...next, ...((points[k + 2] && points[k + 2].ss) || [])], at = seq.indexOf(check); if (at >= 0 && seq[at + 1]) check += " " + seq[at + 1]; } // "paste this:" and then the command
|
|
87
|
+
// "The draft isn't sent." says which draft only in its own point ("**Draft:** it's in Gmail, to …")
|
|
88
|
+
const about = !label && SENDS.test(s) ? points.find((p) => p.label && SENDS.test(p.label) && p.ss.length) : null;
|
|
89
|
+
const what = label ? `${label}: ${s}` : about ? `${s} ${about.label}: ${about.ss[0]}` : s;
|
|
90
|
+
return { kind: HELD.test(s) || ACTS.test(s) ? "approve" : "step", what: clean(what, 240), check: clean(check === s ? "" : check, 240), label: clean(label || (about && about.label), 60) };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
// What an agent handed over: [{ lane, text }] from its .symbiot/HANDOFF.md.
|
|
57
96
|
function parseHandoffs(md) {
|
|
58
97
|
const out = []; let cur = null;
|
|
@@ -65,4 +104,4 @@ function parseHandoffs(md) {
|
|
|
65
104
|
return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs };
|
|
107
|
+
export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
|
package/home.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { watchBoard } from "./watch.mjs";
|
|
16
16
|
import { pendingReview, pushTasks } from "./tasks.mjs";
|
|
17
|
-
import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
|
|
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
20
|
import { CONFIG_DIR, loadTasks } from "./core.mjs";
|
|
@@ -25,6 +25,7 @@ import { resolveProvider } from "./ai.mjs";
|
|
|
25
25
|
import { reportsNews } from "./reports.mjs";
|
|
26
26
|
import { awaitingState, inboxSight } from "./handback.mjs";
|
|
27
27
|
import { newClashes } from "./checks.mjs";
|
|
28
|
+
import { postsState } from "./post.mjs";
|
|
28
29
|
|
|
29
30
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
30
31
|
let cached = null;
|
|
@@ -54,9 +55,11 @@ const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] ===
|
|
|
54
55
|
const FAILED_FOR = 86400000, dropped = new Set();
|
|
55
56
|
const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
|
|
56
57
|
// What stopped and can't go on without you, as questions: a handover that couldn't
|
|
57
|
-
// start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
|
|
58
|
-
// that ended
|
|
59
|
-
//
|
|
58
|
+
// start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), a run
|
|
59
|
+
// that ended waiting on you (agents.mjs needsOf: a draft for your OK, a sudo step,
|
|
60
|
+
// a step your answer left you) with what to check first, and a run that ended in an
|
|
61
|
+
// error. Each is answered on its blob (homeAnswer), on Home and on its lane in
|
|
62
|
+
// Tasks. repo: its lane ("" for an ops run).
|
|
60
63
|
function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
|
|
61
64
|
const out = [], seen = new Set();
|
|
62
65
|
for (const t of stuck) {
|
|
@@ -66,9 +69,16 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
|
|
|
66
69
|
}
|
|
67
70
|
for (const a of list) {
|
|
68
71
|
if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
|
|
72
|
+
if (a.status === "running" || (a.ask && a.ask.questions && a.ask.questions.length)) continue; // at work, or it asked: that's its question
|
|
73
|
+
const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, "").replace(/^\W*What['’]s needed:\W*/i, "").replace(/^./, (c) => c.toUpperCase()), 60);
|
|
74
|
+
const n = a.needs;
|
|
75
|
+
if (n && !dropped.has("needs:" + a.id)) {
|
|
76
|
+
const ok = n.kind === "approve";
|
|
77
|
+
out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
|
|
78
|
+
q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
69
81
|
if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
|
|
70
|
-
if (a.ask && a.ask.questions && a.ask.questions.length) continue; // it asked: that's its question
|
|
71
|
-
const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60);
|
|
72
82
|
const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
|
|
73
83
|
out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
|
|
74
84
|
q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
|
|
@@ -96,6 +106,10 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
96
106
|
const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
|
|
97
107
|
const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
|
|
98
108
|
const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
|
|
109
|
+
const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
|
|
110
|
+
const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
|
|
111
|
+
// Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
|
|
112
|
+
const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
|
|
99
113
|
const named = deps.name || displayName;
|
|
100
114
|
const you = [], map = repos() || {};
|
|
101
115
|
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
@@ -105,6 +119,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
105
119
|
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" });
|
|
106
120
|
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" });
|
|
107
121
|
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" });
|
|
122
|
+
// no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
|
|
123
|
+
// Home didn't say "All handled" truthfully. One click for the one on this computer.
|
|
124
|
+
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 } } : {}) }); }
|
|
108
125
|
for (const r of pending()) {
|
|
109
126
|
if (!r.path || r.running) continue;
|
|
110
127
|
const n = (r.tasks || []).length, files = (r.files || []).length;
|
|
@@ -140,17 +157,23 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
140
157
|
if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
|
|
141
158
|
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 } : {}) }));
|
|
142
159
|
const working = list.filter((a) => a.status === "running").length;
|
|
143
|
-
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
|
|
160
|
+
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
|
|
144
161
|
if (!deps.board) cached = out;
|
|
145
162
|
return out;
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
// An answer on a stuck handover's or a failed run's blob. pick: the
|
|
149
|
-
// (0 goes ahead, 1 skips); text: your own words, which go ahead with
|
|
150
|
-
// they say no. The handover's run (lanes.mjs allowHandover) reports back
|
|
151
|
-
// agent that asked; a
|
|
152
|
-
// ANSWERS.md
|
|
165
|
+
// An answer on a stuck handover's, a waiting run's or a failed run's blob. pick: the
|
|
166
|
+
// option's place (0 goes ahead, 1 skips); text: your own words, which go ahead with
|
|
167
|
+
// them unless they say no. The handover's run (lanes.mjs allowHandover) reports back
|
|
168
|
+
// to the agent that asked; a run waiting on your OK or your step starts again with
|
|
169
|
+
// "go ahead" or "done" in its ANSWERS.md (skipped, it doesn't ask again); a failed
|
|
170
|
+
// run starts again in its folder, with your words in its ANSWERS.md. { ok, said } or { error }.
|
|
153
171
|
const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
|
|
172
|
+
// An answer, in the folder's ANSWERS.md, for the run that picks it up.
|
|
173
|
+
function noteAnswer(path, heading, text) {
|
|
174
|
+
const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
|
|
175
|
+
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
|
|
176
|
+
}
|
|
154
177
|
function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
155
178
|
id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
|
|
156
179
|
const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
|
|
@@ -164,11 +187,22 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
|
164
187
|
const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
|
|
165
188
|
if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
|
|
166
189
|
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
167
|
-
if (text)
|
|
168
|
-
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### Your last run stopped with an error\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {} }
|
|
190
|
+
if (text) noteAnswer(a.path, "Your last run stopped with an error", text);
|
|
169
191
|
const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
|
|
170
192
|
return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
|
|
171
193
|
}
|
|
194
|
+
if (id.startsWith("needs:")) {
|
|
195
|
+
const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
|
|
196
|
+
if (!n) return { error: "That isn't waiting on you any more." };
|
|
197
|
+
const settle = deps.settle || settleNeeds;
|
|
198
|
+
if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
|
|
199
|
+
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
200
|
+
const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
|
|
201
|
+
noteAnswer(a.path, n.what, said);
|
|
202
|
+
const e = (deps.run || runHandoff)(a.path, { force: true });
|
|
203
|
+
if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
|
|
204
|
+
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
|
+
}
|
|
172
206
|
return { error: "Answer that one on its block in Agents." };
|
|
173
207
|
}
|
|
174
208
|
|
package/index.mjs
CHANGED
|
@@ -636,7 +636,7 @@ async function cmdPost() {
|
|
|
636
636
|
if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
|
|
637
637
|
if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
|
|
638
638
|
if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
|
|
639
|
-
console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait
|
|
639
|
+
console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait under Marketing in the app too.`));
|
|
640
640
|
console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
|
|
641
641
|
return;
|
|
642
642
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.54.
|
|
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.54.4",
|
|
4
|
+
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"symbiot": "index.mjs"
|
package/post.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// symbiot — Post: your week's real work as three draft posts (LinkedIn), in your
|
|
2
|
-
// own voice, each waiting on you
|
|
2
|
+
// own voice, each waiting on you under Marketing: Approve, Edit or Skip.
|
|
3
3
|
//
|
|
4
4
|
// - What it writes from: the last 7 days of commits, the release tags (and gh's
|
|
5
5
|
// releases, when gh works) and the CHANGELOG.md sections dated in that window,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
//
|
|
22
22
|
// Stored in ~/.config/symbiot/posts.json, readable by you only:
|
|
23
23
|
// { posts: [{ id, kind, text, show, facts: [n], sources: [fact line], media: [{ id, kind, file, name, from, url?, bytes, added }], platform, status, drafted, edited?, approved?, skipped? }], lastUrl }
|
|
24
|
-
// status: waiting (
|
|
24
|
+
// status: waiting (under Marketing), approved, skipped or replaced (a newer draft took its place).
|
|
25
25
|
import { join, dirname } from "node:path";
|
|
26
26
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync, readdirSync, rmSync, readlinkSync } from "node:fs";
|
|
27
27
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -32,7 +32,7 @@ import { resolveProvider, write } from "./ai.mjs";
|
|
|
32
32
|
import { commits, discoveredRepos } from "./scan.mjs";
|
|
33
33
|
import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
|
|
34
34
|
|
|
35
|
-
const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media") };
|
|
35
|
+
const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media"), test: join(CONFIG_DIR, "post-test.json") };
|
|
36
36
|
const PLATFORM = "linkedin";
|
|
37
37
|
// LinkedIn's share box, opened for you to paste an approved post into.
|
|
38
38
|
const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
|
|
@@ -42,7 +42,7 @@ const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 chara
|
|
|
42
42
|
const q = (s) => JSON.stringify(String(s));
|
|
43
43
|
|
|
44
44
|
const NO_AI = "Symbiot needs an AI to draft your posts, and none is connected. Connect one with symbiot login (or Settings in the app), then run symbiot post again. Nothing was drafted, saved or logged.";
|
|
45
|
-
const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (
|
|
45
|
+
const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Marketing → Link LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
|
|
46
46
|
|
|
47
47
|
// ---- your voice ------------------------------------------------------------------
|
|
48
48
|
// voice.md's examples: what's between lines of ---, each long enough to be a post.
|
|
@@ -62,7 +62,7 @@ const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").
|
|
|
62
62
|
async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
|
|
63
63
|
const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
|
|
64
64
|
if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
|
|
65
|
-
if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn
|
|
65
|
+
if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn (Marketing → Link LinkedIn, or Connections), sign in in the window that opens, close it, then try again." };
|
|
66
66
|
if (r.error) return { error: r.error };
|
|
67
67
|
const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
|
|
68
68
|
if (!found.length) return { error: `Found no posts of yours on LinkedIn's activity page. Paste 5–10 of your posts into ${paths.voice} instead, with a line of --- between each.` };
|
|
@@ -200,7 +200,7 @@ const SYSTEM = `You draft LinkedIn posts for a developer, from their real work t
|
|
|
200
200
|
`Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","show":"…","facts":[1,4]},{"kind":"learned","text":"…","show":"…","facts":[…]},{"kind":"long","text":"…","show":"…","facts":[…]}]}, ` +
|
|
201
201
|
`where "facts" lists the numbers of every fact the post uses, and "show" is one short line (not posted) saying what picture or short video would show it best, ` +
|
|
202
202
|
`for them to take: the screen, page or command the post is about, as the facts name it (e.g. "a picture of the new Reports view", "a clip of the install running").`;
|
|
203
|
-
// Draft this week's 3 posts and put them
|
|
203
|
+
// Draft this week's 3 posts and put them under Marketing (waiting on you). With
|
|
204
204
|
// no AI connected it says so and does nothing else: nothing is read, written or
|
|
205
205
|
// logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
|
|
206
206
|
// and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
|
|
@@ -265,9 +265,10 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
|
|
|
265
265
|
} catch { return false; }
|
|
266
266
|
}
|
|
267
267
|
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 []; } }
|
|
268
|
-
// What
|
|
268
|
+
// What Marketing shows: the drafts waiting on you, the last few you dealt with,
|
|
269
269
|
// and whether it can draft: an AI connected, and your voice (examples in voice.md,
|
|
270
|
-
// or LinkedIn linked to fill it from). Until then
|
|
270
|
+
// or LinkedIn linked to fill it from). Until then Marketing says so in a line, and
|
|
271
|
+
// Home shows no Marketing orb (home.mjs).
|
|
271
272
|
function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
|
|
272
273
|
const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
|
|
273
274
|
let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
|
|
@@ -520,14 +521,71 @@ function openUrl(url) {
|
|
|
520
521
|
|
|
521
522
|
// ---- replies: who might be a customer ------------------------------------------------
|
|
522
523
|
// A comment or mention that asks how to install it, what it costs or about its
|
|
523
|
-
// licence, or about using it in a team: "maybe a customer" on the Dashboard.
|
|
524
|
+
// licence, or about using it in a team: "maybe a customer" under Marketing and on the Dashboard.
|
|
524
525
|
// Gives why ("install", "pricing", "team") or "".
|
|
525
526
|
const CUSTOMER = [
|
|
526
527
|
["pricing", /\b(?:pric(?:e|es|ed|ing)|costs?|how much|licen[cs](?:e|es|ing)|subscriptions?|paid (?:plan|version|tier)|free tier|pay(?:ing)? for|per (?:seat|user|month))\b/i],
|
|
527
528
|
["install", /\b(?:install(?:ing|ed|ation)?|npm i|npx|download(?:ing)?)\b|\bhow (?:do|can|would|could) (?:i|we|you|one) (?:set (?:it |this )?up|get started|start|use|try|get|run)\b|\bwhere (?:can|do) (?:i|we) (?:get|find)\b/i],
|
|
528
529
|
["team", /\b(?:my|our|whole)\s+(?:dev\s+|engineering\s+)?(?:team|teams|company|org|organi[sz]ation|startup)\b|\bteam (?:use|plan|licen[cs]e|version|pricing)\b|\bfor (?:a |small )?teams\b|\b(?:enterprise|seats)\b/i],
|
|
529
530
|
];
|
|
531
|
+
// ---- the 4-week test: a row a week (Marketing) -------------------------------------
|
|
532
|
+
// Does posting bring anyone? Four weeks, a row each. Weeks run from the day of your
|
|
533
|
+
// first draft; week 1 is the first of them a post went out in (until one has, the one
|
|
534
|
+
// you're in: a week with nothing posted doesn't count), or the day post-test.json's
|
|
535
|
+
// start names, when you've moved it ({ "start": "2026-10-13" }). Each row: posts published, and how many
|
|
536
|
+
// carried a picture or video; replies on LinkedIn (its watched notifications, marked
|
|
537
|
+
// by watch.mjs markNews), the ones that may be a customer and those asking what it
|
|
538
|
+
// costs; and npm installs of the packages your posts are about. Profile visits are
|
|
539
|
+
// only in LinkedIn's own analytics. null before there's a first draft.
|
|
540
|
+
const TEST_WEEKS = 4;
|
|
541
|
+
const dayStart = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
|
|
542
|
+
const addDays = (ts, n) => { const d = new Date(ts); d.setDate(d.getDate() + n); return d.getTime(); };
|
|
543
|
+
const ymd = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
|
|
544
|
+
function testPlan(paths = PATHS, now = Date.now()) {
|
|
545
|
+
const posts = loadPosts(paths).posts;
|
|
546
|
+
let set = NaN; try { set = Date.parse(String(JSON.parse(readFileSync(paths.test, "utf8")).start || "").slice(0, 10) + "T00:00:00"); } catch {}
|
|
547
|
+
const first = posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity);
|
|
548
|
+
if (!set && first === Infinity) return null;
|
|
549
|
+
let start = set ? dayStart(set) : dayStart(first);
|
|
550
|
+
if (!set) { const out = postLog(paths).filter((l) => l.action === "approved").map((l) => Number(l.ts)).sort((a, b) => a - b)[0], to = out || now; while (addDays(start, 7) <= to) start = addDays(start, 7); }
|
|
551
|
+
return { start, weeks: Array.from({ length: TEST_WEEKS }, (_, i) => [addDays(start, 7 * i), addDays(start, 7 * (i + 1))]), posts };
|
|
552
|
+
}
|
|
553
|
+
// The npm packages your posts are about: the repos their git facts came from
|
|
554
|
+
// ("[21] symbiot · 2026-10-06 · changelog: …"), each one's package.json name unless private.
|
|
555
|
+
function testPackages(posts, map = {}) {
|
|
556
|
+
const repos = new Set(posts.flatMap((p) => (Array.isArray(p.sources) ? p.sources : []).map((x) => (String(x).match(/^(?:\[\d+\]\s*)?(.+?)\s+·\s/) || [])[1]).filter(Boolean)));
|
|
557
|
+
const out = [];
|
|
558
|
+
for (const r of repos) { if (!map[r]) continue; try { const j = JSON.parse(readFileSync(join(map[r], "package.json"), "utf8")); if (j.name && !j.private && !out.includes(j.name)) out.push(j.name); } catch {} }
|
|
559
|
+
return out.slice(0, 3);
|
|
560
|
+
}
|
|
561
|
+
function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {} } = {}) {
|
|
562
|
+
const plan = testPlan(paths, now); if (!plan) return null;
|
|
563
|
+
const log = postLog(paths);
|
|
564
|
+
const rows = plan.weeks.map(([from, to], i) => {
|
|
565
|
+
const inW = (t) => Number(t) >= from && Number(t) < to, pub = log.filter((l) => l.action === "approved" && inW(l.ts)), rep = news.filter((n) => n.social && inW(n.ts));
|
|
566
|
+
return { week: i + 1, from: ymd(from), to: ymd(addDays(to, -1)), started: now >= from, over: now >= to, published: pub.length, media: pub.filter((l) => (l.media || []).length).length,
|
|
567
|
+
replies: rep.length, customers: rep.filter((n) => n.customer).length, pricing: rep.filter((n) => n.customer === "pricing").length, installs: installs && installs[i] != null ? installs[i] : null };
|
|
568
|
+
});
|
|
569
|
+
return { start: ymd(plan.start), end: ymd(addDays(plan.weeks[TEST_WEEKS - 1][1], -1)), rows, packages: testPackages(plan.posts, map), week: rows.filter((r) => r.started).length };
|
|
570
|
+
}
|
|
571
|
+
// npm's daily downloads for those packages, summed per test week (weeks not begun:
|
|
572
|
+
// null): [n, …] or null. get(url) reads npm's JSON (the app passes it: nothing here
|
|
573
|
+
// goes on the network by itself). One read per package for the whole test, kept a few hours.
|
|
574
|
+
const NPM_DAYS = "https://api.npmjs.org/downloads/range/";
|
|
575
|
+
let npmCache = { key: "", at: 0, counts: null };
|
|
576
|
+
async function testInstalls(t, { now = Date.now(), get = null } = {}) {
|
|
577
|
+
if (!t || !t.packages.length || !t.rows[0].started || typeof get !== "function") return null;
|
|
578
|
+
const key = t.packages.join(",") + "|" + t.start; if (npmCache.key === key && now - npmCache.at < 3 * 3600000) return npmCache.counts;
|
|
579
|
+
const counts = t.rows.map((r) => (r.started ? 0 : null));
|
|
580
|
+
for (const p of t.packages) {
|
|
581
|
+
let d = null; try { d = await get(`${NPM_DAYS}${t.start}:${t.end}/${encodeURIComponent(p)}`); } catch {}
|
|
582
|
+
if (!d || !Array.isArray(d.downloads)) return null;
|
|
583
|
+
for (const x of d.downloads) { const r = t.rows.findIndex((w) => x.day >= w.from && x.day <= w.to); if (r >= 0 && counts[r] != null) counts[r] += Number(x.downloads) || 0; }
|
|
584
|
+
}
|
|
585
|
+
npmCache = { key, at: now, counts };
|
|
586
|
+
return counts;
|
|
587
|
+
}
|
|
530
588
|
function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
|
|
531
589
|
|
|
532
|
-
export { PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
|
|
590
|
+
export { TEST_WEEKS, testWeeks, testInstalls, testPackages, PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
|
|
533
591
|
mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
|
package/server.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { createServer } from "node:http";
|
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
15
15
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
16
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
|
|
16
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
|
|
17
17
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
18
18
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
19
19
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -22,9 +22,9 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
|
|
|
22
22
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
23
23
|
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
24
24
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
|
|
25
|
-
import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
25
|
+
import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
26
26
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
27
|
-
import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
27
|
+
import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
28
28
|
import { mindState, forget } from "./mind.mjs";
|
|
29
29
|
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
30
30
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
@@ -332,13 +332,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
332
332
|
if (u.pathname === "/api/mind") return json(res, mindState());
|
|
333
333
|
if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
|
|
334
334
|
// Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
|
|
335
|
-
if (u.pathname === "/api/links") return json(res, linksState());
|
|
335
|
+
if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
|
|
336
336
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
337
337
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
338
338
|
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
339
339
|
// Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
|
|
340
340
|
// the page copies the post itself and opens LinkedIn's share box; nothing is posted.
|
|
341
341
|
if (u.pathname === "/api/posts") return json(res, postsState());
|
|
342
|
+
// Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
|
|
343
|
+
// and the 4-week test's table, with npm installs of the packages the posts are about
|
|
344
|
+
if (u.pathname === "/api/marketing") {
|
|
345
|
+
const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
|
|
346
|
+
let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
|
|
347
|
+
if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
|
|
348
|
+
return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
|
|
349
|
+
}
|
|
342
350
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
343
351
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
|
|
344
352
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
package/ui.mjs
CHANGED
|
@@ -101,6 +101,9 @@ body.lq-liquid.lq-light.lq-pooled #lqsinkrow{background:rgba(246,248,251,.94);bo
|
|
|
101
101
|
#lqtitle{margin:0;font:650 24px/1.15 var(--sans);letter-spacing:-.02em;color:var(--bone);overflow-wrap:anywhere}
|
|
102
102
|
#lqtitle .lqpk{margin-left:10px;font:600 11.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--amber);vertical-align:middle}
|
|
103
103
|
#lqpark{align-self:center;padding:5px 12px;font-size:12.5px}
|
|
104
|
+
/* what the open panel does to itself (its Refresh) sits beside Close */
|
|
105
|
+
.lqacts,#lqtools{display:flex;align-items:center;gap:8px;flex:0 0 auto}#lqtools:empty,#lqtools:not(:has(>:not(.hidden))){display:none}
|
|
106
|
+
#pushtasks{padding:7px 14px;font-size:13px}
|
|
104
107
|
#lqscene{display:none}
|
|
105
108
|
body.lq-work:not(.lq-pooled) #lqscene{display:block;position:absolute;left:50%;top:50px;transform:translateX(-50%);z-index:3;margin:0;font:650 24px/1.15 var(--sans);letter-spacing:-.02em;color:#F3F0EA;text-shadow:0 1px 12px rgba(0,0,0,.6);pointer-events:none;white-space:nowrap}
|
|
106
109
|
body.lq-light.lq-work #lqscene{color:#151A21;text-shadow:0 1px 10px rgba(255,255,255,.8)}
|
|
@@ -248,6 +251,7 @@ body.lq-talking .lqd.lqblob{opacity:.55}
|
|
|
248
251
|
#firststeps .fst span{font-size:12.5px;color:var(--faint);overflow-wrap:anywhere}
|
|
249
252
|
#firststeps li button{flex:none;padding:5px 12px;font-size:12.5px}
|
|
250
253
|
#needsbox:empty{display:none}
|
|
254
|
+
#pushout .pushed{margin-top:6px;padding:10px 14px}#pushout .pushed .dh{flex-wrap:wrap;row-gap:2px}#pushout .pushed .dd{font-size:12.5px;line-height:1.5}#pushout>.dd{margin-top:4px}
|
|
251
255
|
#needsbox{margin:4px 0 12px}
|
|
252
256
|
#needsbox .nitem{display:flex;flex-direction:column;align-items:stretch;gap:6px;padding:12px 14px;margin-top:8px}
|
|
253
257
|
#needsbox .nitem .nopt{max-width:720px}
|
|
@@ -508,6 +512,8 @@ body.lq-light .cx .lbtn:hover{background:rgba(21,26,33,.05)}
|
|
|
508
512
|
body.lq-light .cx.ok .cxb{box-shadow:0 1px 2px rgba(0,0,0,.25);background:radial-gradient(circle at 34% 28%,#FFFFFF 0,#E3E6EA 22%,#9BA2AC 55%,#3A3F46 100%)}
|
|
509
513
|
.cx.signin .cxb,.cx.signedout .cxb,.cx.error .cxb{opacity:1;box-shadow:inset 0 0 0 2px var(--amber),0 0 10px rgba(242,165,65,.35)}
|
|
510
514
|
.cxn{font:500 13.5px var(--sans);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
515
|
+
.cxa{flex:none;font-size:12px;color:var(--faint);opacity:.55}.cxa.ready{color:var(--bone);opacity:1}
|
|
516
|
+
.cxlg{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px}.cxh .cxlg .cxlgf{display:inline-flex;align-items:center;gap:6px;margin-left:8px;font:inherit;color:inherit}
|
|
511
517
|
.cx.ok .cxn{color:var(--bone)}
|
|
512
518
|
.cxs{margin-left:auto;padding-left:8px;font-size:11.5px;color:var(--faint);white-space:nowrap}
|
|
513
519
|
.cx.signin .cxs,.cx.signedout .cxs,.cx.error .cxs{color:var(--amber)}
|
|
@@ -690,7 +696,8 @@ body.lq-liquid .sset label.check{display:flex;align-items:center;gap:10px;margin
|
|
|
690
696
|
body.lq-liquid .sset .note{font-size:12.5px;max-width:70ch}
|
|
691
697
|
body.lq-liquid .sset .cxh{margin-top:0}
|
|
692
698
|
/* the Close bar carries the panel's own ground, so what scrolls under it stays clean */
|
|
693
|
-
|
|
699
|
+
/* top: the panel's padding (main's 16px), so it sticks at the very top edge: scrolled content no longer shows in a strip above it */
|
|
700
|
+
body.lq-liquid.lq-pooled #lqsinkrow{top:-16px;margin:-14px -14px 10px;padding:10px 10px 8px;background:linear-gradient(180deg,rgba(6,8,11,.97) 70%,rgba(6,8,11,0));border-radius:22px 22px 0 0}
|
|
694
701
|
body.lq-liquid.lq-light.lq-pooled #lqsinkrow{background:linear-gradient(180deg,rgba(246,248,251,.98) 70%,rgba(246,248,251,0))}
|
|
695
702
|
|
|
696
703
|
/* Agents: needs you, at work, finished (folded) */
|
|
@@ -751,6 +758,11 @@ body.lq-light .bwthread{border-top-color:rgba(184,106,12,.45)}
|
|
|
751
758
|
.pshead{display:flex;flex-direction:column;gap:2px;margin:24px 0 10px}
|
|
752
759
|
.pshead span{font:600 15px var(--sans);color:var(--bone)}
|
|
753
760
|
.pshead small{font-size:12.5px;color:var(--faint)}
|
|
761
|
+
/* Marketing's 4-week test: a row a week; the week you're in marked, the ones to come faint */
|
|
762
|
+
.mkwrap{overflow-x:auto}.mktab{width:100%;border-collapse:collapse;font-size:13px}
|
|
763
|
+
.mktab th{font:500 11px var(--sans);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);text-align:left;padding:6px 10px 8px 0;border-bottom:1px solid var(--line);white-space:nowrap}
|
|
764
|
+
.mktab td{padding:9px 10px 9px 0;border-bottom:1px solid var(--line);color:var(--text);vertical-align:baseline}.mktab td b{color:var(--bone)}
|
|
765
|
+
.mktab td.n{font-variant-numeric:tabular-nums}.mktab tr[aria-current] td{color:var(--bone)}.mktab tr[aria-current] td:first-child{box-shadow:inset 2px 0 0 var(--amber);padding-left:8px}
|
|
754
766
|
.pshelf{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:start}
|
|
755
767
|
body.lq-liquid .pshelf .pcard{position:relative;display:flex;flex-direction:column;gap:10px;margin:0;padding:16px 18px;border-radius:20px;border:1px solid rgba(255,255,255,.08);border-left:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.035)}
|
|
756
768
|
body.lq-liquid.lq-light .pshelf .pcard{background:rgba(255,255,255,.75);border-color:rgba(21,26,33,.08)}
|
|
@@ -777,6 +789,9 @@ body.lq-liquid #panel-tasks .task.open .t{display:block;-webkit-line-clamp:unset
|
|
|
777
789
|
#tasklist.onerepo .task .rp,.rcard .task .rp{display:none}
|
|
778
790
|
body.lq-liquid .rcard{border:1px solid rgba(242,165,65,.3);border-radius:20px;padding:16px 18px 14px;background:rgba(255,255,255,.035);box-shadow:none;margin-top:10px}
|
|
779
791
|
body.lq-liquid.lq-light .rcard{background:rgba(255,255,255,.75);border-color:rgba(154,82,0,.25)}
|
|
792
|
+
/* a long card's Approve row sticks to the panel's bottom edge (bottom: main's padding) while the card is in view, so it's never scrolled out of reach */
|
|
793
|
+
body.lq-liquid .rcard>.ract{position:sticky;bottom:-16px;z-index:1;margin:8px -18px 0;padding:12px 18px 10px;background:linear-gradient(180deg,rgba(9,11,15,0),rgba(9,11,15,.97) 14px)}
|
|
794
|
+
body.lq-liquid.lq-light .rcard>.ract{background:linear-gradient(180deg,rgba(248,249,251,0),rgba(248,249,251,.96) 14px)}
|
|
780
795
|
body.lq-liquid .rcard .rhead b{font:600 16px var(--sans);letter-spacing:-.01em}
|
|
781
796
|
body.lq-liquid .rcard .task{margin-top:6px;padding:9px 12px;border-radius:12px}
|
|
782
797
|
.rwork{display:flex;align-items:center;gap:9px;margin:10px 0 2px;font-size:13px;color:var(--text)}
|
|
@@ -853,7 +868,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
853
868
|
.bcard .tchat{border-top:1px solid var(--line);border-radius:9px;margin-top:8px}
|
|
854
869
|
.bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
|
|
855
870
|
@media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
|
|
856
|
-
@media(max-width:600px){#
|
|
871
|
+
@media(max-width:600px){#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
|
|
857
872
|
</style></head><body>
|
|
858
873
|
<div id="liquid" aria-label="Symbiot">
|
|
859
874
|
<canvas id="lq" aria-hidden="true"></canvas>
|
|
@@ -887,11 +902,11 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
887
902
|
<button class="tab" data-tab="todo">Todo</button>
|
|
888
903
|
<button class="tab" data-tab="tasks">Tasks</button>
|
|
889
904
|
<button class="tab" data-tab="agents">Agents</button>
|
|
890
|
-
<button class="tab" data-tab="reports">Reports</button>
|
|
905
|
+
<button class="tab" data-tab="reports">Reports</button><button class="tab" data-tab="marketing">Marketing</button>
|
|
891
906
|
<button class="tab" data-tab="settings">Settings</button>
|
|
892
907
|
</div>
|
|
893
908
|
<main>
|
|
894
|
-
<div id="lqsinkrow"><div class="lqwhere"><span id="lqcrumb" class="lqcrumb">Home</span><h2 id="lqtitle" aria-live="polite"></h2><button type="button" class="ghost hidden" id="lqpark"></button></div><button class="iconbtn wide" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc, or right-click)"><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Close</button></div>
|
|
909
|
+
<div id="lqsinkrow"><div class="lqwhere"><span id="lqcrumb" class="lqcrumb">Home</span><h2 id="lqtitle" aria-live="polite"></h2><button type="button" class="ghost hidden" id="lqpark"></button></div><div class="lqacts"><span id="lqtools"><button class="ghost hidden" type="button" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></span><button class="iconbtn wide" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc, or right-click)"><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Close</button></div></div>
|
|
895
910
|
<section id="panel-map">
|
|
896
911
|
<div class="profile muted" id="profile">Mapping your work…</div>
|
|
897
912
|
<div class="maprow">
|
|
@@ -929,7 +944,6 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
929
944
|
<div id="bstream" class="bstream"><canvas id="bsc" aria-hidden="true"></canvas><div id="bsov"></div></div>
|
|
930
945
|
<div id="boardtalk"></div>
|
|
931
946
|
<div id="boardawait"></div>
|
|
932
|
-
<div id="boardposts"></div>
|
|
933
947
|
<details id="blist" class="blist"><summary>All of it as a list</summary><div id="board" class="board"></div></details>
|
|
934
948
|
<div id="boardlinks"></div>
|
|
935
949
|
</section>
|
|
@@ -939,8 +953,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
939
953
|
<div class="rfoot" id="outfoot" style="display:none"></div>
|
|
940
954
|
</section>
|
|
941
955
|
<section id="panel-tasks" class="hidden">
|
|
942
|
-
<
|
|
943
|
-
<details class="tnhow"><summary>How tasks work</summary><div class="note muted">To give tasks to your agent, use <b>Send to repos</b> — filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>↶</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b><i class=ic-chat></i></b> on a task to ask questions about it.</div></details>
|
|
956
|
+
<details class="tnhow"><summary>How tasks work</summary><div class="note muted">To add a task, tell Symbiot in the bar at the bottom: it files it under the right project. To give tasks to your agent, use <b>Send to repos</b> at the top, beside Close — filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>↶</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b><i class=ic-chat></i></b> on a task to ask questions about it.</div></details>
|
|
944
957
|
<div id="needsbox"></div>
|
|
945
958
|
<div id="pushout"></div>
|
|
946
959
|
<div id="reviewout"></div>
|
|
@@ -949,7 +962,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
949
962
|
<div id="tasklist"></div>
|
|
950
963
|
</section>
|
|
951
964
|
<section id="panel-agents" class="hidden">
|
|
952
|
-
<div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="iconbtn" id="agentsrefresh"
|
|
965
|
+
<div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="iconbtn" id="agentsrefresh" title="Refresh" aria-label="Refresh"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
|
953
966
|
<div id="agentsmsg"></div>
|
|
954
967
|
<div id="laneslist"></div>
|
|
955
968
|
<div id="agentslist"></div>
|
|
@@ -959,6 +972,13 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
959
972
|
<div id="reportslist"></div>
|
|
960
973
|
<div id="reportview"></div>
|
|
961
974
|
</section>
|
|
975
|
+
<section id="panel-marketing" class="hidden">
|
|
976
|
+
<div class="row"><span class="muted" style="flex:1">What you post about your work, and who answers: your drafts, the replies to them, and the 4-week test of whether posting brings anyone. Symbiot never posts: you do, on LinkedIn.</span></div>
|
|
977
|
+
<div id="mkmsg"></div>
|
|
978
|
+
<div id="boardposts"></div>
|
|
979
|
+
<div id="mkreplies"></div>
|
|
980
|
+
<div id="mktest"></div>
|
|
981
|
+
</section>
|
|
962
982
|
<section id="panel-drift" class="hidden">
|
|
963
983
|
<div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
|
|
964
984
|
<label class="muted swl" style="margin-left:auto"><input type="checkbox" class="sw" id="driftfetch"> Fetch latest</label>
|
|
@@ -1091,9 +1111,10 @@ $('panel-tasks').classList.toggle('hidden',!isTasks);
|
|
|
1091
1111
|
$('panel-drift').classList.toggle('hidden',!isDrift);
|
|
1092
1112
|
$('panel-agents').classList.toggle('hidden',!isAgents);
|
|
1093
1113
|
$('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
|
|
1114
|
+
$('panel-marketing').classList.toggle('hidden',tab!=='marketing');if(tab==='marketing')loadMarketing();
|
|
1094
1115
|
if(isRun){$('what').textContent=tab;wuHead();WUTEXT='';$('out').textContent=WUEMPTY;$('out').classList.add('muted');$('copy').classList.add('hidden');$('outfoot').style.display='none';if(tab==='week')showLatestWeek();}
|
|
1095
1116
|
if(isMap&&!mapLoaded)loadMap();
|
|
1096
|
-
if(isTasks)
|
|
1117
|
+
if(isTasks)loadTasks();
|
|
1097
1118
|
if(isDrift&&!driftLoaded)loadDrift();
|
|
1098
1119
|
if(isAgents)loadAgents(); else stopAgentsPoll();
|
|
1099
1120
|
lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
|
|
@@ -1134,6 +1155,9 @@ var tf=typeof TFILTER!=='undefined'&&TFILTER||{},arch=typeof TARCH!=='undefined'
|
|
|
1134
1155
|
if(proj)name=lqProjName(proj);var parked=!!proj&&PARKED.repos.indexOf(proj)>=0;
|
|
1135
1156
|
if(c)c.textContent=proj?'Projects':'Home';t.innerHTML=esc(name)+(parked?"<span class='lqpk'>parked</span>":'');
|
|
1136
1157
|
var pk=$('lqpark');if(pk){pk.classList.toggle('hidden',!proj);pk.textContent=parked?'Unpark':'Park this project';pk.title=parked?'let its tasks start agent runs again':'stop its tasks starting agent runs, until you unpark it';pk.setAttribute('data-repo',proj);}
|
|
1158
|
+
// the open panel's own Refresh sits beside Close, not under it on the panel's top line
|
|
1159
|
+
var tl=$('lqtools');if(tl&&tl.appendChild)['agents','reports'].forEach(function(p){var b=$(p+'refresh'),row=$('panel-'+p);row=row&&row.querySelector('.row');if(!b)return;if(p===current){if(b.parentNode!==tl)tl.appendChild(b);}else if(b.parentNode===tl&&row&&row.appendChild)row.appendChild(b);});
|
|
1160
|
+
var pt=$('pushtasks');if(pt&&pt.classList)pt.classList.toggle('hidden',current!=='tasks'||!!arch);
|
|
1137
1161
|
try{document.title=name+' \\u00b7 Symbiot';}catch(e){}}
|
|
1138
1162
|
function lqPark(repo,on,then,path){api('/api/lanes/park',{repo:repo,path:path||'',on:on}).then(function(r){if(r&&r.error){if(then)then(r.error);return;}loadParked(function(){lqTitle();if(then)then('');if(LQ.scene==='work')lqLoadWork();});});}
|
|
1139
1163
|
function loadParked(then){api('/api/lanes/parked').then(function(p){PARKED={repos:(p&&p.repos)||[],paths:(p&&p.paths)||[]};if(then)then();}).catch(function(){if(then)then();});}
|
|
@@ -1380,7 +1404,7 @@ else if(ur)h+="<div class='muted' style='margin:6px 0'><span class='err'>Unrelea
|
|
|
1380
1404
|
// The version is already released (its v* tag exists, or npm has it) and these changes keep it:
|
|
1381
1405
|
// Approve bumps it in the PR too, a patch unless you pick otherwise.
|
|
1382
1406
|
var bo=r.bumpOffer;if(bo&&r.files.length)h+="<div class='row' style='margin:6px 0;font-size:12px'><span class='muted'>Version</span><select class='bumpsel' title='bump the version in package.json (and the lockfile) in this PR' style='flex:0 0 auto;width:auto'><option value='patch'>bump to "+esc(bo.patch)+" (patch)</option><option value='minor'>bump to "+esc(bo.minor)+" (minor)</option><option value=''>keep "+esc(bo.version)+"</option></select></div>";
|
|
1383
|
-
h+="<div class='row'>"+(pq&&r.path?"<button class='act answerq' data-path='"+esc(r.path).replace(/'/g,''')+"'>Answer its question"+(pq>1?"s":"")+"</button>":"")+"<button class='"+(pd?'ghost':'act')+" approve'"+(pd?" data-partly='1'":"")+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" · agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task → PR":" title='commit on a branch, push, open a PR, then archive'>Approve → "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
|
|
1407
|
+
h+="<div class='row ract'>"+(pq&&r.path?"<button class='act answerq' data-path='"+esc(r.path).replace(/'/g,''')+"'>Answer its question"+(pq>1?"s":"")+"</button>":"")+"<button class='"+(pd?'ghost':'act')+" approve'"+(pd?" data-partly='1'":"")+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" · agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task → PR":" title='commit on a branch, push, open a PR, then archive'>Approve → "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
|
|
1384
1408
|
el.innerHTML=h;
|
|
1385
1409
|
el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
|
|
1386
1410
|
card.querySelectorAll('.task').forEach(function(row){wireAsk(row,row.getAttribute('data-id'));});
|
|
@@ -1459,6 +1483,13 @@ api('/api/agents/skip',{path:a.path,text:s.text}).then(function(r){if(!r||r.erro
|
|
|
1459
1483
|
// A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
|
|
1460
1484
|
function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");if(w.cmd)fs=(fs?fs+" or ":"")+"the agent command in Settings → Handoff";
|
|
1461
1485
|
return "<div class='aq'><h4><i class=ic-pause></i> Waiting on your step</h4><div class='qt'><i class=ic-person></i> "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
|
|
1486
|
+
// A run that ended waiting on you (agents.mjs needsOf): your OK for what it would send or
|
|
1487
|
+
// change, or a step only you can do, with what to check first. Answered as on Home.
|
|
1488
|
+
function needsHtml(a){var n=a.needs;if(!n||n.waiting)return '';var ok=n.kind==='approve';
|
|
1489
|
+
return "<div class='aq'><h4><i class=ic-person></i> "+(ok?"Waits for your OK":"Needs you")+"</h4><div class='qt'>"+esc(n.what)+"</div>"+(n.check?"<div class='qc'><b>Check first:</b> "+esc(n.check)+"</div>":"")
|
|
1490
|
+
+"<div class='row'><button class='act ngo' data-id='"+esc(a.id)+"' title='"+(ok?"its agent goes ahead with it":"its agent checks it worked and carries on")+"'>"+(ok?"Go ahead":"Done it")+"</button><button class='ghost nskip' data-id='"+esc(a.id)+"' title='it stops asking'>Skip</button></div></div>";}
|
|
1491
|
+
function wireNeeds(el){el.querySelectorAll('.ngo,.nskip').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;var msg=document.getElementById('agentsmsg');
|
|
1492
|
+
api('/api/home/answer',{id:'needs:'+a.id,pick:btn.classList.contains('ngo')?0:1}).then(function(r){msg.innerHTML=r&&!r.error?"<div class='note ok'>"+esc(a.name)+": "+esc(r.said||'Done.')+"</div>":"<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;msg.innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
|
|
1462
1493
|
function wireWaits(el){el.querySelectorAll('.wstart').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;
|
|
1463
1494
|
api('/api/open',{path:a.path,force:true}).then(function(x){var msg=document.getElementById('agentsmsg');msg.innerHTML=x&&x.opened?"<div class='note ok'>✓ Started your agent in <b>"+esc(a.name)+"</b>.</div>":"<div class='note err'>"+esc(a.name)+": "+(x&&x.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.")+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
|
|
1464
1495
|
// What a run left for Symbiot's memory (.symbiot/REMEMBER.json, handback.mjs): nothing goes in until Remember
|
|
@@ -1515,7 +1546,7 @@ function loadAgents(){loadLanes();loadParked(agentsDraw);}
|
|
|
1515
1546
|
// Agents, in order: what needs you (a question, a step of yours), what's at work, and
|
|
1516
1547
|
// what's finished, folded away (open stays open across refreshes).
|
|
1517
1548
|
var AGDONEOPEN=false;
|
|
1518
|
-
function agentGroups(list,fn){var needs=[],run=[],done=[];list.forEach(function(a){if(nQs(a)||a.waiting)needs.push(a);else if(a.status==='running')run.push(a);else done.push(a);});
|
|
1549
|
+
function agentGroups(list,fn){var needs=[],run=[],done=[];list.forEach(function(a){if(nQs(a)||a.waiting||a.needs)needs.push(a);else if(a.status==='running')run.push(a);else done.push(a);});
|
|
1519
1550
|
var g=function(t,xs){return xs.length?"<div class='tgroup agg'>"+t+" <span class='tcount'>"+xs.length+"</span></div>"+xs.map(fn).join(''):'';};
|
|
1520
1551
|
return g('Needs you',needs)+g('At work',run)+(done.length?"<details class='agdone'"+(AGDONEOPEN?' open':'')+"><summary class='tgroup agg'>Finished <span class='tcount'>"+done.length+"</span></summary>"+done.map(fn).join('')+"</details>":'');}
|
|
1521
1552
|
function agentsDraw(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
|
|
@@ -1529,6 +1560,7 @@ if(a.fromHeld)st+=" · started on the tasks held for the last run";
|
|
|
1529
1560
|
if(a.earlier)st+=" · "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
|
|
1530
1561
|
if(a.waiting)st+=" · <span style='color:var(--amber)'>waiting on your step</span>";
|
|
1531
1562
|
if(nQs(a))st+=" · <span style='color:var(--amber)'>needs your answers</span>";
|
|
1563
|
+
if(a.needs&&!a.needs.waiting)st+=" · <span style='color:var(--amber)'>"+(a.needs.kind==='approve'?"waits for your OK":"needs you")+"</span>";
|
|
1532
1564
|
if(a.remember&&a.remember.length)st+=" · <span style='color:var(--amber)'>found "+a.remember.length+" thing"+(a.remember.length>1?"s":"")+" to remember</span>";
|
|
1533
1565
|
if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "• "+esc(t).replace(/'/g,''');}).join(' ');
|
|
1534
1566
|
ht+=(hn?' ':'')+(a.status==='running'?"Sent while this agent was running. They wait in .symbiot/TASKS.next.md, replace TASKS.md when it finishes (keeping its ticks), and an agent starts on them then.":"They wait in .symbiot/TASKS.next.md for the agent running in this folder to finish. After that, an agent starts on them the next time the Tasks tab checks this repo, or send again.");
|
|
@@ -1536,7 +1568,7 @@ st+=" · <span style='color:var(--amber);cursor:help' title='"+ht+"'><i cl
|
|
|
1536
1568
|
// a repo's run can be parked from here in one click: its tasks start no runs until it's unparked
|
|
1537
1569
|
var pk=!/\\/drafts\\/act-[^/]*$/.test(String(a.path||''))&&a.path,parked=pk&&PARKED.paths.indexOf(a.path)>=0;
|
|
1538
1570
|
if(parked)st+=" · <span style='color:var(--amber)'>parked: no new runs</span>";
|
|
1539
|
-
var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(String(a.name||'').split('**').join(''))+"</span><span class='dd'>"+st+"</span>"+(pk?"<button type='button' class='ghost apark' data-on='"+(parked?'0':'1')+"' title='"+(parked?"let its tasks start agent runs again":"stop its tasks starting agent runs, until you unpark it")+"'>"+(parked?'Unpark':'Park')+"</button>":"")+"</div>";
|
|
1571
|
+
var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(String(a.name||'').split('**').join('').replace(/^(Agent:\s*)What['’]s needed:\s*/i,'$1'))+"</span><span class='dd'>"+st+"</span>"+(pk?"<button type='button' class='ghost apark' data-on='"+(parked?'0':'1')+"' title='"+(parked?"let its tasks start agent runs again":"stop its tasks starting agent runs, until you unpark it")+"'>"+(parked?'Unpark':'Park')+"</button>":"")+"</div>";
|
|
1540
1572
|
b+=workHtml(a);
|
|
1541
1573
|
var ch=a.changed;
|
|
1542
1574
|
if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
|
|
@@ -1548,12 +1580,12 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
|
|
|
1548
1580
|
}else if(a.status==='done'){
|
|
1549
1581
|
b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
|
|
1550
1582
|
}
|
|
1551
|
-
b+=waitHtml(a)+askHtml(a)+factsHtml(a);
|
|
1583
|
+
b+=waitHtml(a)+needsHtml(a)+askHtml(a)+factsHtml(a);
|
|
1552
1584
|
b+=a.work?("<details class='wkraw'><summary>what it said, in full</summary><pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(nothing yet)')+"</pre></details>"):("<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>");
|
|
1553
1585
|
return "<div class='agent' data-i='"+list.indexOf(a)+"'>"+b+"</div>";};
|
|
1554
1586
|
el.innerHTML=agentGroups(list,agentBlock);var dn=el.querySelector('.agdone');if(dn)dn.addEventListener('toggle',function(){AGDONEOPEN=dn.open;});
|
|
1555
1587
|
el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
|
|
1556
|
-
restoreDrafts(el);wireAsks(el);wireWaits(el);wireFacts(el);lqLight();
|
|
1588
|
+
restoreDrafts(el);wireAsks(el);wireWaits(el);wireNeeds(el);wireFacts(el);lqLight();
|
|
1557
1589
|
el.querySelectorAll('.apark').forEach(function(btn){btn.addEventListener('click',function(){var g=AGENTLIST[+btn.closest('.agent').getAttribute('data-i')];if(!g)return;btn.disabled=true;lqPark('',btn.getAttribute('data-on')==='1',function(err){btn.disabled=false;$('agentsmsg').innerHTML=err?"<div class='note err'>"+esc(err)+"</div>":'';loadAgents();},g.path);});});
|
|
1558
1590
|
stopAgentsPoll();if(anyRunning&¤t==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
|
|
1559
1591
|
function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos…</div>";
|
|
@@ -1568,16 +1600,15 @@ var clean=repos.filter(function(r){return !r.flags.length;}).map(function(r){ret
|
|
|
1568
1600
|
if(clean.length)h+="<div class='muted' style='margin-top:10px'>clean: "+esc(clean.join(", "))+"</div>";
|
|
1569
1601
|
if(!repos.length)h+="<div class='muted'>No repos found under "+esc(d.base||"your home folder")+".</div>";
|
|
1570
1602
|
out.innerHTML=h;});}
|
|
1571
|
-
function fillTaskRepos(){var sel=document.getElementById('newtaskrepo');if(!sel||!GRAPH)return;var cur=sel.value;var names=GRAPH.nodes.filter(function(n){return n.type==='repo'||n.type==='folder';}).map(function(n){return n.label;}).sort();sel.innerHTML="<option value=''>repo…</option>"+names.map(function(n){return "<option value='"+esc(n)+"'"+(n===cur?" selected":"")+">"+esc(n)+"</option>";}).join("");}
|
|
1572
|
-
function addTaskUI(){var i=document.getElementById('newtask');var v=(i.value||'').trim();if(!v)return;var repo=(document.getElementById('newtaskrepo')||{}).value||'';api('/api/tasks/add',{text:v,repo:repo}).then(function(){i.value='';TFILTER={type:'',repo:''};loadTasks();});}
|
|
1573
1603
|
function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
|
|
1574
1604
|
o.innerHTML="<div class='muted' style='margin-top:10px'><span class='dot-c' style='background:var(--amber)'></span> Writing .symbiot/TASKS.md into your repos…</div>";
|
|
1575
1605
|
api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){
|
|
1576
|
-
if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send.
|
|
1577
|
-
var n=(r.written||[]).length;var opens=[];
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
if(r.
|
|
1606
|
+
if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send. Tell Symbiot what to do in the bar at the bottom, or tick ideas in a repo review.</div>";return;}
|
|
1607
|
+
var n=(r.written||[]).length;var opens=[];
|
|
1608
|
+
// one line, its paths on hover: the review cards below it stay in view
|
|
1609
|
+
var h="<div class='drift pushed'><div class='dh'><span class='dot-c'></span><span class='dn'>Sent to "+n+" repo"+(n===1?"":"s")+"</span><span class='dd'>"+(r.written||[]).map(function(w){return "<span title='"+esc(w.file).replace(/'/g,''')+"'>✓ <b>"+esc(w.name)+"</b> ("+w.count+" task"+(w.count===1?"":"s")+(w.held?", <i class=ic-pause></i> held till its running agent finishes":"")+")</span>";}).join(" · ")+"</span></div>";
|
|
1610
|
+
if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'><i class=ic-warn></i> not sent: "+esc(names)+". "+(hasNoRepo?"Tell Symbiot in the bar at the bottom which project it's for, so it has somewhere to go.":"That repo isn't in the map — add its folder in Settings.")+"</div>";}
|
|
1611
|
+
if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.blocked){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-pause></i> <b>"+esc(w.name)+"</b>: "+esc(x.note)+" <button class='ghost' style='padding:2px 8px;font-size:12px'>Start it anyway</button>";d.querySelector('button').addEventListener('click',function(){this.disabled=true;api('/api/open',{path:w.path,force:true}).then(function(y){d.innerHTML=y&&y.opened?"✓ Started an agent in <b>"+esc(w.name)+"</b>.":"<i class=ic-warn></i> <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-warn></i> <b>"+esc(w.name)+"</b> already has an agent running, so another wasn't started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd'><i class=ic-bot></i> Handed "+n+" repo"+(n===1?"":"s")+" to your agent — opening the <b>Agents</b> tab to watch it work…</div>";setTimeout(function(){setTab('agents');},500);}
|
|
1581
1612
|
else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
|
|
1582
1613
|
h+="</div>";
|
|
1583
1614
|
o.innerHTML=h;
|
|
@@ -1671,10 +1702,17 @@ function escQ(t){return esc(t).replace(/'/g,''');}
|
|
|
1671
1702
|
// Connections: a column per kind of site; each site a bead in the look: solid when
|
|
1672
1703
|
// linked, amber when it wants you to sign in, hollow when it isn't linked. Link,
|
|
1673
1704
|
// check (↻) and Unlink show on hover (always on touch).
|
|
1674
|
-
|
|
1705
|
+
// The plug marks the sites your agent's runs can use too, before you link them: only
|
|
1706
|
+
// those Claude has a connector for, and only with Claude Code as your agent (agents.mjs
|
|
1707
|
+
// linkReach). Faint until you've connected it in claude.ai.
|
|
1708
|
+
function reachOf(d,id){var r=d.reach,s=r&&r.claude&&r.sites&&r.sites[id];return s||null;}
|
|
1709
|
+
function linksHtml(d,board){var anyR=d.items.some(function(it){return reachOf(d,it.id);});
|
|
1710
|
+
var h="<div class='cxh'><span>Connections</span><small>"+(d.linked?"What Symbiot watches for you. Link more to see what arrives there, here and in Week and Standup.":"Link your work to see what arrives there, here and in Week and Standup.")+"</small>"
|
|
1711
|
+
+(anyR?"<small class='cxlg'><i class='ic-plug cxa ready' aria-hidden='true'></i> your agent's runs can use it too, through Claude's connector for it <span class='cxlgf'><i class='ic-plug cxa' aria-hidden='true'></i> faint: once you connect it in claude.ai (Settings → Connectors)</span></small>":"")+"</div>";
|
|
1675
1712
|
if(d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
|
|
1676
1713
|
h+="<div class='cxg'>"+d.groups.map(function(g){return "<div class='cxc'><div class='cxl'>"+esc(g)+"</div>"+d.items.filter(function(x){return x.group===g;}).map(function(it){var on=it.state!=='off';
|
|
1677
|
-
|
|
1714
|
+
var rc=reachOf(d,it.id),rt=rc?(rc.ready?" Your agent's runs can use it too, through Claude's "+rc.connector.replace(/^claude\.ai\s+/i,'')+" connector.":" Your agent's runs can use it too once you connect "+rc.name+" in claude.ai → Settings → Connectors (only you can sign in there)."):'';
|
|
1715
|
+
return "<div class='lnk cx "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button type='button' class='lbtn' title='"+escQ(String(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it')).replace(/\.?\s*$/,rt?'.':'')+rt)+"'><span class='cxb' aria-hidden='true'></span><span class='cxn'>"+esc(it.name)+"</span>"+(rc?"<i class='ic-plug cxa"+(rc.ready?" ready":"")+"' role='img' aria-label='"+escQ(rc.ready?"agent runs can use it too":"agent runs can use it once connected in claude.ai")+"'></i>":"")+"<span class='cxs'>"+(on?esc(LINKWORD[it.state]||''):'Link')+"</span></button>"+
|
|
1678
1716
|
(on?"<button type='button' class='lmore' title='check it now' aria-label='Check "+escQ(it.name)+" now'>↻</button><button type='button' class='lrm' title='unlink: stop watching it and stop trusting it'>Unlink</button>":"")+"</div>";}).join('')+"</div>";}).join('')+"</div>";return h;}
|
|
1679
1717
|
function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
|
|
1680
1718
|
function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
|
|
@@ -1741,7 +1779,7 @@ function scanLine(s){return 'Mapping your work… '+s.phase+(s.total?' '+s.done+
|
|
|
1741
1779
|
function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();var done=false;
|
|
1742
1780
|
function poll(){if(done)return;api('/api/scan').then(function(s){if(done)return;if(s&&s.active&&s.phase)p.textContent=scanLine(s);setTimeout(poll,600);}).catch(function(){});}
|
|
1743
1781
|
setTimeout(poll,400);
|
|
1744
|
-
api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;
|
|
1782
|
+
api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);lmBuild(g);view={k:1,x:0,y:0};p.innerHTML=profileLine(g)+(g.stats&&g.stats.partial?" · <span class='err'>partial — the scan hit its time limit</span>":"");var af=$('allowfiles');if(af)af.addEventListener('click',function(){SymbiotAndroid.storage();});var ut=$('usetermux');if(ut)ut.addEventListener('click',function(){SymbiotAndroid.openTermux();});render();});}
|
|
1745
1783
|
// The Android app calls this when you come back having allowed file access, so
|
|
1746
1784
|
// the map rescans instead of staying empty.
|
|
1747
1785
|
window.symbiotStorageGranted=function(){if(current==='map')loadMap();else mapLoaded=false;};
|
|
@@ -1886,8 +1924,9 @@ function copyPost(text,done){function fallback(){var t=document.createElement('t
|
|
|
1886
1924
|
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(text).then(function(){done(true);},fallback);else fallback();}
|
|
1887
1925
|
function renderPosts(){var p=POSTS,el=$('boardposts');if(!el||!p)return;var L=p.labels||{},h='';
|
|
1888
1926
|
// it can't draft yet (no AI, or neither LinkedIn linked nor examples in voice.md): one line, not a block a new user meets first
|
|
1889
|
-
if(!p.posts.length&&p.canDraft===false){var need=[];if(!p.connected)need.push('an AI is connected (Settings)');if(!p.linkedin&&!p.voice.count)need.push('LinkedIn is linked
|
|
1890
|
-
el.innerHTML="<div class='
|
|
1927
|
+
if(!p.posts.length&&p.canDraft===false){var need=[];if(!p.connected)need.push('an AI is connected (Settings)');if(!p.linkedin&&!p.voice.count)need.push('LinkedIn is linked, or <code>'+esc(p.voice.file)+'</code> has a few of your posts');
|
|
1928
|
+
el.innerHTML="<div class='pshead'><span>Drafts to post</span><small>Symbiot drafts your week's LinkedIn posts from your git, in your voice, once "+need.join(', and ')+".</small></div>"+(!p.linkedin?"<div class='row'><button class='ghost plink' title='sign in to LinkedIn once in Symbiot's browser: it reads your posts for your voice, and replies to them show here'>Link LinkedIn</button></div>":"");
|
|
1929
|
+
var pl=el.querySelector('.plink');if(pl)pl.addEventListener('click',function(){pl.disabled=true;api('/api/links/link',{id:'linkedin'}).then(function(r){pl.disabled=false;mkMsg(r&&!r.error?'ok':'err',r&&!r.error?"Opened LinkedIn in Symbiot's browser. Sign in as you normally do, then close that window.":esc((r&&r.error)||'failed'));});});return;}
|
|
1891
1930
|
if(p.posts.length){h+="<div class='pshead'><span>Drafts to post</span><small>"+p.posts.length+" waiting on you · Approve copies one and opens LinkedIn, where you post it yourself</small></div><div class='pshelf'>";
|
|
1892
1931
|
h+=p.posts.map(function(x){var ed=Object.prototype.hasOwnProperty.call(PEDIT,x.id);
|
|
1893
1932
|
return "<div class='rcard pcard"+(ed?" editing":"")+"' data-id='"+escQ(x.id)+"'><div class='rhead'><b>"+esc(L[x.kind]||x.kind)+"</b><span class='muted'>LinkedIn · drafted "+agoTxt(x.drafted)+(x.edited?" · edited":"")+"</span></div>"
|
|
@@ -1944,6 +1983,29 @@ POSTS.posts=r.posts;renderPosts();var said=[];if(r.retried)said.push("The first
|
|
|
1944
1983
|
var vb=el.querySelector('.pvoice');if(vb)vb.addEventListener('click',function(){vb.disabled=true;postsMsg('','');vb.textContent='Reading LinkedIn…';
|
|
1945
1984
|
api('/api/posts/voice',{confirmed:true}).then(function(r){vb.disabled=false;vb.textContent='Fill from LinkedIn';if(!r||r.error){postsMsg('err',esc((r&&r.error)||'failed'));return;}
|
|
1946
1985
|
postsMsg('ok',"✓ Added "+r.added+" of your LinkedIn posts: "+r.total+" in <code>"+esc(r.file)+"</code>. Read them over, and delete any that don't sound like you.");loadPostsUI();}).catch(function(e){vb.disabled=false;vb.textContent='Fill from LinkedIn';postsMsg('err',esc(String((e&&e.message)||e)));});});}
|
|
1986
|
+
// ---- Marketing: what you post about your work (post.mjs), who answers, and the 4-week test ----
|
|
1987
|
+
// Off the Dashboard, which shows only what you watch. Its orb shows on Home once posts
|
|
1988
|
+
// can be drafted, or are (home.mjs marketing).
|
|
1989
|
+
var MKT=null;
|
|
1990
|
+
function mkMsg(cls,html){var o=$('mkmsg');if(o)o.innerHTML=html?"<div class='note "+cls+"'>"+html+"</div>":'';}
|
|
1991
|
+
function loadMarketing(){loadPostsUI();api('/api/marketing').then(function(d){if(d&&!d.error){MKT=d;renderMarketing();}});}
|
|
1992
|
+
var MKDAY=function(s){var d=new Date(s+'T00:00:00');return isNaN(d)?s:d.toLocaleDateString(undefined,{day:'numeric',month:'short'});};
|
|
1993
|
+
function renderMarketing(){var d=MKT,r=$('mkreplies'),t=$('mktest');if(!d||!r||!t)return;var rs=d.replies||[];
|
|
1994
|
+
r.innerHTML="<div class='pshead'><span>Replies</span><small>"+(!d.linkedin?"Link LinkedIn, and comments on and mentions of your posts show here, marked when they may be a customer.":rs.length?"On LinkedIn in the last 30 days. Draft a reply leaves one in the comment box for you to post.":"None on LinkedIn in the last 30 days. Comments on and mentions of your posts show here, marked when they may be a customer.")+"</small></div>"
|
|
1995
|
+
+rs.map(function(n){return "<div class='task' data-id='"+escQ(n.id)+"'><span class='t'>"+esc(n.text)+custTag(n)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span><button class='ghost mkdraft' title='your agent drafts a reply in your voice and leaves it in the comment box, never posted'>Draft a reply</button></div>";}).join('');
|
|
1996
|
+
r.querySelectorAll('.mkdraft').forEach(function(b){b.addEventListener('click',function(){var id=b.closest('.task').getAttribute('data-id');b.disabled=true;
|
|
1997
|
+
api('/api/watch/draft',{id:id}).then(function(x){b.disabled=false;mkMsg(x&&!x.error?'ok':'err',x&&!x.error?DRAFTING_SOCIAL:esc((x&&x.error)||'failed'));}).catch(function(e){b.disabled=false;mkMsg('err',esc(String((e&&e.message)||e)));});});});
|
|
1998
|
+
var T4=d.test;if(!T4){t.innerHTML='';return;}
|
|
1999
|
+
var pk=(T4.packages||[]).join(', '),now=T4.week,over=T4.rows.every(function(x){return x.over;});
|
|
2000
|
+
var cell=function(x,v,extra){return x.started?"<td class='n'>"+v+(extra?" <span class='muted'>("+extra+")</span>":"")+"</td>":"<td class='n muted'>–</td>";};
|
|
2001
|
+
var h="<div class='pshead'><span>The 4-week test</span><small>"+(over?"Done: "+MKDAY(T4.start)+" to "+MKDAY(T4.end)+".":now?"Week "+now+" of 4 ("+MKDAY(T4.start)+" to "+MKDAY(T4.end)+").":"Starts "+MKDAY(T4.start)+", runs to "+MKDAY(T4.end)+".")+" Does posting bring anyone? Real replies, installs or pricing questions say it does.</small></div>"
|
|
2002
|
+
+"<div class='mkwrap'><table class='mktab'><thead><tr><th>Week</th><th>Posts published</th><th>Replies</th><th title='LinkedIn's own count: Who viewed your profile'>Profile visits</th><th"+(pk?" title='npm downloads of "+escQ(pk)+"'":"")+">Installs</th><th>Pricing questions</th></tr></thead><tbody>"
|
|
2003
|
+
+T4.rows.map(function(x){return "<tr"+(x.started?"":" class='later'")+(x.started&&!x.over?" aria-current='true'":"")+"><td><b>"+x.week+"</b> <span class='muted'>"+MKDAY(x.from)+"–"+MKDAY(x.to)+"</span></td>"
|
|
2004
|
+
+cell(x,x.published,x.media?x.media+" with a picture":"")+cell(x,x.replies,x.customers?x.customers+" maybe a customer":"")
|
|
2005
|
+
+(x.started?"<td class='n muted' title='LinkedIn's own count: its analytics on each post, and Who viewed your profile'>on LinkedIn</td>":"<td class='n muted'>–</td>")
|
|
2006
|
+
+cell(x,x.installs==null?"–":Number(x.installs).toLocaleString())+cell(x,x.pricing)+"</tr>";}).join('')+"</tbody></table></div>"
|
|
2007
|
+
+"<div class='muted' style='font-size:12px;margin-top:6px'>"+(pk?"Installs: npm downloads of "+esc(pk)+". Releases, mirrors and CI download too, so trust replies and pricing questions more. ":"")+"Replies and pricing questions come from LinkedIn's notifications, which Symbiot watches once LinkedIn is linked.</div>";
|
|
2008
|
+
t.innerHTML=h;}
|
|
1947
2009
|
// ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
|
|
1948
2010
|
var BOARD=null;
|
|
1949
2011
|
// a chat's line says who its last message is from (watch.mjs fromOf): only unread ones count as waiting on you
|
|
@@ -1953,7 +2015,7 @@ var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
|
|
|
1953
2015
|
var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
|
|
1954
2016
|
function talking(){var f=document.activeElement;return BTALKBUSY>0||!!(f&&f.closest&&f.closest('.btalk'));}
|
|
1955
2017
|
var BOARDH=24;
|
|
1956
|
-
function loadBoard(){api('/api/watch/board?hours='+BOARDH).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});loadAwaiting();
|
|
2018
|
+
function loadBoard(){api('/api/watch/board?hours='+BOARDH).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});loadAwaiting();}
|
|
1957
2019
|
function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
|
|
1958
2020
|
|
|
1959
2021
|
// Replies an agent's email waits on (handback.mjs): Symbiot watches your inbox for them, and hands each on when it's in
|
|
@@ -2176,7 +2238,6 @@ document.getElementById('addtrusted').addEventListener('click',addTrustedUI);
|
|
|
2176
2238
|
document.getElementById('newtrusted').addEventListener('keydown',function(e){if(e.key==='Enter')addTrustedUI();});
|
|
2177
2239
|
document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
|
|
2178
2240
|
document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
|
|
2179
|
-
document.getElementById('addtask').addEventListener('click',addTaskUI);
|
|
2180
2241
|
document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
|
|
2181
2242
|
document.getElementById('agentcmd').addEventListener('change',saveAgent);
|
|
2182
2243
|
document.getElementById('granttoolbtn').addEventListener('click',function(){grant('tool');});
|
|
@@ -2188,7 +2249,6 @@ document.getElementById('mailaddrs').addEventListener('change',saveMailAddrs);
|
|
|
2188
2249
|
document.getElementById('addmail').addEventListener('click',addMailUI);
|
|
2189
2250
|
document.getElementById('newmail').addEventListener('keydown',function(e){if(e.key==='Enter')addMailUI();});
|
|
2190
2251
|
document.getElementById('mailpreview').addEventListener('click',previewMail);
|
|
2191
|
-
document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
|
|
2192
2252
|
document.getElementById('capture').addEventListener('click',captureUI);
|
|
2193
2253
|
document.getElementById('screenload').addEventListener('click',pickImageUI);
|
|
2194
2254
|
document.getElementById('mappage').addEventListener('click',function(){mapUI($('pagesite').value,$('mappage'));});
|
|
@@ -2336,7 +2396,7 @@ list.forEach(function(d){d.ax=d.tx;d.ay=d.ty;});lqRelax(list,S,cx,cy,92*S.s);
|
|
|
2336
2396
|
var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
|
|
2337
2397
|
LQ.drops=list.map(function(d){var o=old[d.id];d.x=o?o.x:cx;d.y=o?o.y:cy;d.vx=o?o.vx:0;d.vy=o?o.vy:0;d.cr=o?o.cr:0;return d;});
|
|
2338
2398
|
var n=w.projectCount||list.length,nr=(w.running||[]).length,nw=w.waitingCount||0;
|
|
2339
|
-
LQ.coreText=n?n+' project'+(n>1?'s':'')+(nr?' \u00b7 '+nr+' at work':'')+(nw?' \u00b7 '+nw+' tasks waiting':''):'No projects with work on them yet.
|
|
2399
|
+
LQ.coreText=n?n+' project'+(n>1?'s':'')+(nr?' \u00b7 '+nr+' at work':'')+(nw?' \u00b7 '+nw+' tasks waiting':''):'No projects with work on them yet. Tell me what to do.';
|
|
2340
2400
|
var go=$('lqgo');if(go){var k=w.canGo||0;go.textContent=k?'Go \u00b7 start '+k+' task'+(k>1?'s':''):'';if(go.classList)go.classList.toggle('on',k>0);}
|
|
2341
2401
|
lqLabels();}
|
|
2342
2402
|
function lqGoWork(){var go=$('lqgo');if(go)go.disabled=true;api('/api/work/go',{}).then(function(r){if(go)go.disabled=false;
|
|
@@ -2352,7 +2412,7 @@ LQ.talk.push({me:false,text:t});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.r
|
|
|
2352
2412
|
// comes out of the core (mass is conserved). Your colours come from the system,
|
|
2353
2413
|
// live: light or dark, contrast, transparency, forced colours, your accent, night.
|
|
2354
2414
|
var LQ={drops:[],btns:[],core:{x:0,y:0,cr:0},mode:'aware',last:'',lastAct:Date.now(),talk:[],talking:false,theme:{},ripple:[0.5,0.5,-10],t:0,pointer:null,touch:false,frame:0};
|
|
2355
|
-
var LQNAMES={board:'Dashboard',map:'Map',tasks:'Tasks',agents:'Agents',week:'Week',standup:'Standup',todo:'Todo',drift:'Drift',settings:'Settings',reports:'Reports'};
|
|
2415
|
+
var LQNAMES={board:'Dashboard',map:'Map',tasks:'Tasks',agents:'Agents',week:'Week',standup:'Standup',todo:'Todo',drift:'Drift',settings:'Settings',reports:'Reports',marketing:'Marketing'};
|
|
2356
2416
|
var LQ_REST=60000,LQ_MAX=11;
|
|
2357
2417
|
function lqMM(q){try{return !!(window.matchMedia&&window.matchMedia(q).matches);}catch(e){return false;}}
|
|
2358
2418
|
function lqLookGet(){var l='';try{l=window.localStorage&&window.localStorage.getItem('symbiot-look')||'';}catch(e){}return l==='glass'||l==='pearl'?l:'ferro';}
|
|
@@ -2374,7 +2434,7 @@ function lqLoad(commit){if(LQ.scene==='work'){lqLoadWork();return;}var fresh=LQ.
|
|
|
2374
2434
|
// (work with an end) up left, Areas (what you keep up with) up right, Resources (to
|
|
2375
2435
|
// look things up in) down left, the Archive (what's done) sunk low; and what's new on
|
|
2376
2436
|
// what you watch (Watching) as a zone of its own on the right.
|
|
2377
|
-
var PARA_OF={tasks:'p',agents:'p',todo:'p',board:'a',week:'a',standup:'a',map:'r',drift:'r',settings:'r',reports:'r'};
|
|
2437
|
+
var PARA_OF={tasks:'p',agents:'p',todo:'p',board:'a',week:'a',standup:'a',marketing:'a',map:'r',drift:'r',settings:'r',reports:'r'};
|
|
2378
2438
|
var PARA_NAME={p:'Projects',a:'Areas',w:'Watching',r:'Resources',x:'Archive'},PARA_SUB={p:'work with an end',a:'what you keep up with',w:'new on what you watch',r:'to look things up in',x:'what’s done'};
|
|
2379
2439
|
var PARA_ANG={p:-2.55,a:-0.6,w:0.3,r:2.6,x:1.25},PARA_LIVE={p:0.45,a:0.3,w:0.3,r:0.12,x:0};
|
|
2380
2440
|
function lqPara(d){return d.kind==='you'?'n':d.kind==='archive'?'x':d.kind==='feed'&&d.shape!=='reports'?'w':PARA_OF[d.shape]||'p';}
|
|
@@ -2419,7 +2479,9 @@ else if((d.kind==='you'||d.kind==='feed')&&at[d.shape]!=null)links.push([at[d.sh
|
|
|
2419
2479
|
function lqBuild(){var a=LQ.adapt||{},h=LQ.home||{},S=lqSize(),list=[];LQ.bw=S.w;LQ.bh=S.h;
|
|
2420
2480
|
// the band first: everything else sits under it, round a core centred in what's left
|
|
2421
2481
|
var need=lqNeeds(h,S),top=need.bottom,cx=S.w/2,cy=need.items.length?Math.max(S.h*0.47,(top+60+S.h-200)/2):S.h*0.47;LQ.cy=cy;LQ.top=top;LQ.need=need;
|
|
2422
|
-
|
|
2482
|
+
// Marketing only once posts can be drafted, or are (home.mjs): a new user meets what they watch first
|
|
2483
|
+
var mk=function(id){return id!=='marketing'||!!h.marketing;};
|
|
2484
|
+
var feeds=(h.feeds||[]).slice(0,2),lay=((a.layout&&a.layout.items)||[]).filter(function(it){return mk(it.id);}),more=((a.layout&&a.layout.more)||[]).filter(mk);
|
|
2423
2485
|
var vx=Math.max(0.5,Math.min(S.s*1.3,(S.w/2-90)/400)),vy=Math.max(0.35,Math.min(S.s*0.8,(S.h-cy-230)/400,(cy-(top?top+60:160))/400));var rs=Math.min(S.s,(vx+vy)/1.5);var room=Math.max(3,LQ_MAX-feeds.length-1-(need.items.length?2:0));lay.slice(room).forEach(function(it){more.push(it.id);});
|
|
2424
2486
|
var repNew=(h.feeds||[]).filter(function(f){return f.id==='feed:reports';})[0],repOut=repNew&&feeds.indexOf(repNew)<0; // new reports, when their own droplet didn't fit
|
|
2425
2487
|
lay.slice(0,room).forEach(function(it){list.push({id:'shape:'+it.id,kind:'shape',shape:it.id,title:LQNAMES[it.id]||it.id,sub:it.id==='reports'&&repOut?repNew.count+' new':'',r:it.r*rs,tx:cx+Math.cos(it.angle)*it.d*vx,ty:cy+Math.sin(it.angle)*it.d*vy});});
|
|
@@ -2447,12 +2509,14 @@ LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
|
|
|
2447
2509
|
lqLabels();}
|
|
2448
2510
|
// Home in zones, on a screen wide enough for them: the core in the middle of what's
|
|
2449
2511
|
// under the band; Projects then Resources to its left, Areas then Watching (with the
|
|
2450
|
-
// Archive and "more" at its end) to its right; each a row with its name at its
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2512
|
+
// Archive and "more" at its end) to its right; each a row with its name at its outer
|
|
2513
|
+
// end, so the droplets, which flow out of the core, never cross a name on their way
|
|
2514
|
+
// (on the right, names next to the core had the Dashboard orb sitting on "what you
|
|
2515
|
+
// keep up with" until it landed). Fixed places in a fixed order, so nothing overlaps
|
|
2516
|
+
// and each part is where it was last time. Sizes still come from how you use them (adapt.mjs), all scaled
|
|
2453
2517
|
// by one factor when the screen is short, so they stay comparable. null when they
|
|
2454
2518
|
// can't fit (a narrow or very short screen): the liquid's own layout then.
|
|
2455
|
-
var ZONE_ORDER=['tasks','agents','todo','board','week','standup','map','drift','settings','reports'],ZONE_KIND={shape:0,feed:1,archive:2,more:3};
|
|
2519
|
+
var ZONE_ORDER=['tasks','agents','todo','board','week','standup','marketing','map','drift','settings','reports'],ZONE_KIND={shape:0,feed:1,archive:2,more:3};
|
|
2456
2520
|
function lqZones(list,S,top,coreR){LQ.zoneT=null;if(S.w<1000||S.h<540)return null;
|
|
2457
2521
|
var y0=top?top+10:96,y1=S.h-180,mid=(y0+y1)/2,cx=S.w/2,gapC=coreR+36,colW=S.w/2-gapC-20,titleW=150;if(colW-titleW<250||mid-y0<70)return null;
|
|
2458
2522
|
var cells={p:[cx-gapC-colW,y0,mid],a:[cx+gapC,y0,mid],r:[cx-gapC-colW,mid,y1],w:[cx+gapC,mid,y1]},by={},plan={},k=1;
|
|
@@ -2466,8 +2530,8 @@ var R=0;m.forEach(function(d){R=Math.max(R,d.r);});var av=colW-titleW,fitW=funct
|
|
|
2466
2530
|
if(fitW(per)<0.6&&m.length>1)per=Math.ceil(m.length/2);var rows=Math.ceil(m.length/per);
|
|
2467
2531
|
k=Math.min(k,fitW(per),((c[2]-c[1])/rows-46)/(2*R));plan[g]={m:m,per:per,rows:rows,stepX:av/per};});
|
|
2468
2532
|
if(k<0.55)return null;k=Math.min(1,k);LQ.zoneT={};
|
|
2469
|
-
Object.keys(plan).forEach(function(g){var c=cells[g],p=plan[g],rowH=(c[2]-c[1])/p.rows,x0=c[0]+titleW+(colW-titleW)/2;
|
|
2470
|
-
LQ.zoneT[g]=[c[0]+titleW/2,c[1]+rowH/2-13];
|
|
2533
|
+
Object.keys(plan).forEach(function(g){var c=cells[g],p=plan[g],rowH=(c[2]-c[1])/p.rows,right=g==='a'||g==='w',x0=c[0]+(right?0:titleW)+(colW-titleW)/2;
|
|
2534
|
+
LQ.zoneT[g]=[right?c[0]+colW-titleW/2:c[0]+titleW/2,c[1]+rowH/2-13];
|
|
2471
2535
|
p.m.forEach(function(d,i){d.r*=k;var row=Math.floor(i/p.per),n=Math.min(p.per,p.m.length-row*p.per);d.tx=x0+(i%p.per-(n-1)/2)*p.stepX;d.ty=c[1]+row*rowH+rowH/2-13;});});
|
|
2472
2536
|
return {cy:mid};}
|
|
2473
2537
|
// Force-directed layout, solved once per change: push overlapping targets apart
|
|
@@ -2543,7 +2607,7 @@ function lqGo(shape){if(shape==='tasks'||shape==='agents'){lqWorkBy('task');retu
|
|
|
2543
2607
|
function blobOpt(o){return String(o||'').replace(/[ ]*[(]recommended[)][ ]*$/i,'');}
|
|
2544
2608
|
function blobBody(y,home){var h='',nm=y.name||y.repo||(y.kind==='setup'?y.title:'')||'';
|
|
2545
2609
|
h+=home?"<button type='button' class='lt' title='open "+escQ(nm)+", with this lit up'><span>"+esc(lqShort(nm,30))+"</span><i>open ›</i></button>":"<div class='nhead'><b>"+esc(nm)+(y.kind==='ask'?" asks":"")+"</b>"+(y.more?"<span class='muted'>+"+y.more+" more on its block in Agents</span>":"")+"</div>";
|
|
2546
|
-
if(y.kind!=='ask')return h+"<div class='bq'>"+esc(y.kind==='approve'?String(y.sub||'').replace(/ · only you decide$/,''):String(y.sub||'').charAt(0).toUpperCase()+String(y.sub||'').slice(1)+'.')+"</div><div class='bo'><button type='button' class='nopt rec ngo'><span class='bt'>"+(y.kind==='approve'?'Review and approve':y.id==='setup:inbox'?(y.signin?'Sign in again':'Show me your inbox'):y.id==='setup:ai'?'Connect an AI':y.urgent?'Reconnect':'Open Settings')+"</span></button></div>";
|
|
2610
|
+
if(y.kind!=='ask')return h+"<div class='bq'>"+esc(y.kind==='approve'?String(y.sub||'').replace(/ · only you decide$/,''):String(y.sub||'').charAt(0).toUpperCase()+String(y.sub||'').slice(1)+'.')+"</div><div class='bo'><button type='button' class='nopt rec ngo'><span class='bt'>"+(y.kind==='approve'?'Review and approve':y.id==='setup:inbox'?(y.signin?'Sign in again':'Show me your inbox'):y.id==='setup:ai'?'Connect an AI':y.pick?'Use '+y.pick.name:y.urgent?'Reconnect':'Open Settings')+"</span></button></div>";
|
|
2547
2611
|
var os=y.options||[];h+="<div class='bq' title='"+escQ((y.q||'')+(y.why?' ('+y.why+')':''))+"'>"+esc(y.q||y.sub||'')+"</div>";
|
|
2548
2612
|
if(y.fix==='handover'&&y.sub)h+="<div class='bfor' title='"+escQ(y.sub)+"'>for: "+esc(y.sub)+"</div>";
|
|
2549
2613
|
if(os.length)h+="<div class='bo'>"+os.map(function(o,j){return "<button type='button' class='nopt"+(j===0&&/[(]recommended[)]/i.test(o)?' rec':'')+"' data-j='"+j+"' title='"+escQ(o)+"'><span class='bt'>"+whoHtml(blobOpt(o))+"</span></button>";}).join('')+"</div><button type='button' class='bfree' aria-expanded='false'>or answer in your own words</button>";
|
|
@@ -2563,7 +2627,10 @@ box.querySelectorAll('.nopt[data-j]').forEach(function(b){b.addEventListener('cl
|
|
|
2563
2627
|
var fr=box.querySelector('.bfree'),fx=box.querySelector('.bfx');
|
|
2564
2628
|
if(fr&&fx)fr.addEventListener('click',function(){var shut=fx.classList.toggle('hidden');fr.setAttribute('aria-expanded',shut?'false':'true');var i=fx.querySelector('input');if(!shut&&i&&i.focus)i.focus();});
|
|
2565
2629
|
if(fx)fx.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();var i=fx.querySelector('input');send(i&&i.value);});
|
|
2566
|
-
var go=box.querySelector('.ngo');if(go)go.addEventListener('click',function(){lqFocus(y);
|
|
2630
|
+
var go=box.querySelector('.ngo');if(go)go.addEventListener('click',function(){if(!y.pick){lqFocus(y);return;}
|
|
2631
|
+
// Pick your agent: the one Symbiot found, in one click; Settings has the others
|
|
2632
|
+
lock(true);api('/api/agentcmd',{cmd:y.pick.tmpl}).then(function(r){if(!r||r.error){lock(false);say((r&&r.error)||'It didn’t save. Pick it in Settings.',true);return;}
|
|
2633
|
+
say('Your agent is '+y.pick.name+' now: Send to repos and Go hand your tasks to it.');if(box.classList)box.classList.add('gone');LQ.fresh=true;setTimeout(function(){if(o.done)o.done();else lqLoad(false);},650);}).catch(function(e){lock(false);say(String((e&&e.message)||e),true);});});}
|
|
2567
2634
|
function lqSaid(t){LQ.talk.push({me:false,text:t});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);}
|
|
2568
2635
|
// A lit orb opens its project with what lit it lit up and in view (lqLight): a
|
|
2569
2636
|
// repo's questions and its Approve, under "Needs you here" at the top of its
|
|
@@ -2644,7 +2711,10 @@ if(still){d.x=tx;d.y=ty;d.vx=0;d.vy=0;}else{d.vx+=ax;d.vy+=ay;d.x+=d.vx;d.y+=d.v
|
|
|
2644
2711
|
if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max((d.blob?60:140)+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
|
|
2645
2712
|
d.cr+=(tr-d.cr)*(still?1:0.06);
|
|
2646
2713
|
var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){b.style.transform=d.blob?'translate('+Math.round(d.x+d.r+30)+'px,'+Math.round(d.y-d.r-4)+'px)':'translate('+Math.round(d.x-(b.offsetWidth||0)/2)+'px,'+Math.round(d.y+d.cr+(d.kind==='run'?28:4))+'px)';var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';var lt=LQ.lts&&LQ.lts[i];if(lt)lt.tabIndex=show?0:-1;}}
|
|
2647
|
-
(LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt)gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';else
|
|
2714
|
+
(LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt)gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';else{var gx=Math.max(12,Math.min(lqSize().w-gw-12,sx/nn-gw/2)),gy=top-gh-30;
|
|
2715
|
+
// off any droplet it would sit on (the "more" droplet, another group's): above it instead
|
|
2716
|
+
for(var pass=0;pass<3;pass++)D.forEach(function(d){if(d.blob||lqPara(d)===g&&d.kind!=='more')return;var l=d.x-d.cr-6,r=d.x+d.cr+6,t=d.y-d.cr-6,b=d.y+d.cr+(d.lw?24:6);if(gx<r&&gx+gw>l&&gy<b&&gy+gh>t)gy=t-gh-4;});
|
|
2717
|
+
gb.style.transform='translate('+Math.round(gx)+'px,'+Math.round(Math.max(LQ.scene!=='work'&&LQ.top?LQ.top:48,gy))+'px)';}var gshow=!rest&&!pool&&!talk;gb.style.opacity=gshow?'1':'0';gb.style.pointerEvents=gshow?'auto':'none';gb.tabIndex=gshow?0:-1;});
|
|
2648
2718
|
var ntl=$('lqneedt');if(ntl&&ntl.style)ntl.style.opacity=rest?'0':'1';
|
|
2649
2719
|
(LQ.orbs||[]).forEach(function(o){var d=D[+o.getAttribute('data-i')];if(!d||!o.style)return;var R=Math.round(d.cr+20);o.style.width=o.style.height=(2*R)+'px';o.style.transform='translate('+Math.round(d.x-R)+'px,'+Math.round(d.y-R)+'px)';o.style.opacity=rest||pool?'0':'1';});
|
|
2650
2720
|
var cb=$('lqcore');if(cb&&cb.style){var inC=LQ.theme&&LQ.theme.look!=='pearl'&&core.cr>60;cb.style.maxWidth=inC?Math.round(core.cr*1.5)+'px':'340px';var cw=cb.offsetWidth||0,chh=cb.offsetHeight||0;cb.style.transform='translate('+Math.round(cx-cw/2)+'px,'+(rest||inC?(inC?Math.round(core.y-chh/2):Math.round(core.y+core.cr+14)):LQ.scene==='work'?112:56)+'px)';var ct=rest?(LQ.coreText||'All handled'):talk||pool?'':(LQ.coreText||'');lqCoreText(cb,ct);cb.style.opacity=ct?'1':'0';cb.style.pointerEvents=ct?'auto':'none';}}
|