symbiot 0.55.1 → 0.55.3
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 +19 -0
- package/README.md +142 -50
- package/agents.mjs +136 -6
- package/ai.mjs +79 -7
- package/guard.mjs +6 -2
- package/handback.mjs +2 -2
- package/handover.mjs +14 -3
- package/home.mjs +185 -35
- package/index.mjs +17 -9
- package/lanes.mjs +6 -6
- package/marketing.mjs +130 -0
- package/mind.mjs +26 -13
- package/package.json +4 -3
- package/reports.mjs +3 -3
- package/scan.mjs +9 -2
- package/server.mjs +23 -12
- package/tasks.mjs +14 -10
- package/ui.mjs +307 -132
- package/watch.mjs +5 -5
- package/writeups.mjs +3 -3
package/index.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { fileURLToPath } from "node:url";
|
|
|
32
32
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
33
33
|
import { VERSION, CONFIG_PATH, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
|
|
34
34
|
import { HANDOFF_PROMPT, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, parseQuestions, agentQuestions } from "./agents.mjs";
|
|
35
|
-
import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
35
|
+
import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel, claudeState, connectProvider } from "./ai.mjs";
|
|
36
36
|
import { setScanOptions, scanBase, authorship, readmeInfo, repoShape, houseRules, reportFooter, findAllRepos, buildMap } from "./scan.mjs";
|
|
37
37
|
import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.mjs";
|
|
38
38
|
import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
|
|
@@ -118,7 +118,7 @@ function ask(question, { secret = false } = {}) {
|
|
|
118
118
|
|
|
119
119
|
const AUTH_HELP =
|
|
120
120
|
c.y("Symbiot needs an AI to write your updates. Connect one:\n") +
|
|
121
|
-
" " + c.b("symbiot login") + c.d("
|
|
121
|
+
" " + c.b("symbiot login") + c.d(" your Claude subscription (Claude Code, no key), Claude, OpenAI, Gemini or a local model") + "\n" +
|
|
122
122
|
c.d(" Or set a key in your environment: ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.");
|
|
123
123
|
// What writing with the AI (ai.mjs) shows in the terminal.
|
|
124
124
|
Object.assign(AI_UI, {
|
|
@@ -215,26 +215,34 @@ async function cmdLogin() {
|
|
|
215
215
|
const flagProvider = flag("provider", null);
|
|
216
216
|
const existing = resolveProvider();
|
|
217
217
|
if (existing && !flagProvider && !flag("key", null) && !has("force")) {
|
|
218
|
-
console.log(c.g("✓ ") + `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`);
|
|
218
|
+
console.log(c.g("✓ ") + (existing.provider === "claude" ? "Already connected: your Claude subscription, through Claude Code signed in here. No key needed." : `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`));
|
|
219
219
|
console.log(c.d(" Switch or replace it with `symbiot login --force`."));
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
let provider = flagProvider;
|
|
224
224
|
if (!provider) {
|
|
225
|
+
const cs = claudeState(true);
|
|
225
226
|
console.log("\n" + c.b("Connect Symbiot") + "\n");
|
|
226
227
|
console.log("Which AI should Symbiot write your updates with?\n");
|
|
227
|
-
console.log(" 1) " + PROVIDERS.
|
|
228
|
-
console.log(" 2) " + PROVIDERS.
|
|
229
|
-
console.log(" 3) " + PROVIDERS.
|
|
230
|
-
console.log(" 4) " + PROVIDERS.
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
console.log(" 1) " + PROVIDERS.claude.label + c.d(cs.signedIn ? " — signed in here, no key" : cs.installed ? " — Claude Code is here; sign in first (run claude )" : " — needs Claude Code, signed in (no key)"));
|
|
229
|
+
console.log(" 2) " + PROVIDERS.anthropic.label + c.d(" — needs an Anthropic API key"));
|
|
230
|
+
console.log(" 3) " + PROVIDERS.openai.label + c.d(" — needs an OpenAI API key"));
|
|
231
|
+
console.log(" 4) " + PROVIDERS.gemini.label + c.d(" — needs a Google AI API key"));
|
|
232
|
+
console.log(" 5) " + PROVIDERS.ollama.label + c.d(" — runs on your machine, no key"));
|
|
233
|
+
const pick = (await ask("\nChoose 1-5 [1]: ")) || "1";
|
|
234
|
+
provider = { 1: "claude", 2: "anthropic", 3: "openai", 4: "gemini", 5: "ollama" }[pick] || (PROVIDERS[pick] ? pick : "claude");
|
|
233
235
|
}
|
|
234
236
|
if (!PROVIDERS[provider]) { console.log(c.y("Unknown provider: " + provider)); return; }
|
|
235
237
|
const meta = PROVIDERS[provider];
|
|
236
238
|
const cfg = loadConfig();
|
|
237
239
|
|
|
240
|
+
if (provider === "claude") {
|
|
241
|
+
const r = await connectProvider({ provider: "claude", model: flag("model", null) || "" });
|
|
242
|
+
if (!r.ok) { console.log(c.y(r.message)); process.exitCode = 1; return; }
|
|
243
|
+
console.log(c.g("✓ ") + r.message + " Try: " + c.b("symbiot week"));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
238
246
|
if (provider === "ollama") {
|
|
239
247
|
const baseUrl = (flag("base-url", null) || (await ask("Ollama URL [http://localhost:11434]: ")) || "").trim() || "http://localhost:11434";
|
|
240
248
|
const model = (flag("model", null) || (await ask(`Model name [${meta.model}]: `)) || "").trim() || meta.model;
|
package/lanes.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
|
24
24
|
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
|
|
25
25
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
26
26
|
import { actNow } from "./mind.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { laneMap } from "./scan.mjs";
|
|
28
28
|
import { parseRun, lastRunText, readRunLog } from "./work.mjs";
|
|
29
29
|
import { OPS, parseHandoffs } from "./handover.mjs";
|
|
30
30
|
|
|
@@ -53,7 +53,7 @@ function laneTarget(lane, map) {
|
|
|
53
53
|
|
|
54
54
|
// Pick up what a folder's agent handed over and start the lanes it named.
|
|
55
55
|
// Each handover is started once (its key: where from, which lane, what).
|
|
56
|
-
function dispatch(path, { map =
|
|
56
|
+
function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
|
|
57
57
|
const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
|
|
58
58
|
const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
|
|
59
59
|
const started = [];
|
|
@@ -151,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
|
|
|
151
151
|
|
|
152
152
|
// One pass: start what's been handed over, from every lane, then report back
|
|
153
153
|
// what's done. The app runs it every 20 seconds.
|
|
154
|
-
function lanesTick({ map =
|
|
154
|
+
function lanesTick({ map = laneMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
|
|
155
155
|
const ledger = loadLedger();
|
|
156
156
|
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
157
157
|
const started = [];
|
|
@@ -208,10 +208,10 @@ function pastIt(e, hs) {
|
|
|
208
208
|
}
|
|
209
209
|
// The errored handovers that still wait on you, newest first:
|
|
210
210
|
// [{ id, from, text, error, at, dirs, q, options }].
|
|
211
|
-
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
211
|
+
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs, within = STUCK_FOR } = {}) {
|
|
212
212
|
const hs = ledger.handoffs, out = [];
|
|
213
213
|
hs.forEach((e) => {
|
|
214
|
-
if (e.status !== "error" || e.skipped || now - (e.at || 0) >
|
|
214
|
+
if (e.status !== "error" || e.skipped || now - (e.at || 0) > within) return;
|
|
215
215
|
if (pastIt(e, hs)) return;
|
|
216
216
|
const dirs = dirsOf(e.text, e.from.path);
|
|
217
217
|
out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
|
|
@@ -286,7 +286,7 @@ async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
|
|
|
286
286
|
if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
|
|
287
287
|
cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
|
|
288
288
|
let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
|
|
289
|
-
const moves = await orcaMoves(repos, map ||
|
|
289
|
+
const moves = await orcaMoves(repos, map || laneMap(), { remoteOf });
|
|
290
290
|
for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
|
|
291
291
|
return { moves };
|
|
292
292
|
}
|
package/marketing.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// symbiot — Marketing: a lane of its own, for marketing work across every product the
|
|
2
|
+
// user makes (Symbiot, Dailify, the agent products…), not one repo's. Tasks go to it as
|
|
3
|
+
// "marketing" (a lane, the way ops is one), from the Marketing page, any chat, or
|
|
4
|
+
// another lane's handover; its agent works in ~/.config/symbiot/marketing, a git repo
|
|
5
|
+
// of its own (local only, no remote), so what it drafts is reviewed and approved like
|
|
6
|
+
// any repo's work (Approve commits it there). Each item is tagged with the product it
|
|
7
|
+
// markets: "[Dailify] …" as written, else the product its words name. Home shows the
|
|
8
|
+
// lane as an orb among the projects, lit when something there needs you (home.mjs
|
|
9
|
+
// marketingState). Low level on purpose: scan.mjs (laneMap) imports it.
|
|
10
|
+
import { join, relative, basename } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { CONFIG_DIR } from "./core.mjs";
|
|
14
|
+
import { MARKETING } from "./handover.mjs";
|
|
15
|
+
|
|
16
|
+
const MARKETING_DIR = join(CONFIG_DIR, "marketing");
|
|
17
|
+
const DRAFTS = "drafts";
|
|
18
|
+
const README = `# Marketing
|
|
19
|
+
|
|
20
|
+
Symbiot's marketing lane: posts, demo videos and screenshots, launches and campaigns for
|
|
21
|
+
every product, not one repo's. Its agent drafts here, one folder per product under
|
|
22
|
+
\`${DRAFTS}/\`; you review and approve each piece under Marketing in Symbiot, and post it
|
|
23
|
+
yourself. Nothing here is ever posted, published or sent by an agent.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// The lane's folder, made the first time a task goes to it: a README (its name on the
|
|
27
|
+
// Workdesk), .symbiot kept out of git, and a first commit, so Approve has a branch to
|
|
28
|
+
// commit on. Your git name and email when you have them, else Symbiot's.
|
|
29
|
+
function ensureMarketing(dir = MARKETING_DIR) {
|
|
30
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(join(dir, DRAFTS), { recursive: true });
|
|
33
|
+
if (!existsSync(join(dir, "README.md"))) writeFileSync(join(dir, "README.md"), README);
|
|
34
|
+
if (!existsSync(join(dir, ".gitignore"))) writeFileSync(join(dir, ".gitignore"), ".symbiot/\n");
|
|
35
|
+
} catch { return ""; }
|
|
36
|
+
const git = (args, env = {}) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", timeout: 20000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } });
|
|
37
|
+
if (git(["init", "-q", "-b", "main"]).status !== 0 && git(["init", "-q"]).status !== 0) return "";
|
|
38
|
+
const me = String(git(["config", "user.email"]).stdout || "").trim();
|
|
39
|
+
const who = me ? {} : { GIT_AUTHOR_NAME: "Symbiot", GIT_AUTHOR_EMAIL: "symbiot@localhost", GIT_COMMITTER_NAME: "Symbiot", GIT_COMMITTER_EMAIL: "symbiot@localhost" };
|
|
40
|
+
git(["add", "-A"]);
|
|
41
|
+
git(["commit", "-q", "-m", "Marketing: a lane of its own"], who);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A project's name as people say it (Home's droplets, and the products to tag by): the
|
|
46
|
+
// folder can lag behind (CallForge AI is Dailify). The README's title when it's a short name, else package.json's name
|
|
47
|
+
// when it's a word, else the folder's. Read once per folder.
|
|
48
|
+
const NAMES = new Map();
|
|
49
|
+
function displayName(path, folder) {
|
|
50
|
+
folder = String(folder || ""); if (!path) return folder;
|
|
51
|
+
if (NAMES.has(path)) return NAMES.get(path);
|
|
52
|
+
const read = (f) => { try { return readFileSync(join(path, f), "utf8"); } catch { return ""; } };
|
|
53
|
+
const plainName = (s) => { s = String(s || "").replace(/[*`[\]]|^_+|_+$/g, "").trim(); return s && s.length <= 32 && s.split(/\s+/).length <= 4 && !/[:—–|]|\s-\s/.test(s) ? s : ""; };
|
|
54
|
+
const h1 = plainName((read("README.md").match(/^#\s+(.+?)\s*#*\s*$/m) || [])[1]);
|
|
55
|
+
let pkg = ""; try { pkg = String(JSON.parse(read("package.json")).name || "").replace(/^@[^/]+\//, ""); } catch {}
|
|
56
|
+
const name = h1 || (/^[a-z][a-z0-9-]{1,30}$/i.test(pkg) && pkg.toLowerCase() !== folder.toLowerCase().replace(/\s+/g, "-") ? pkg.charAt(0).toUpperCase() + pkg.slice(1) : "") || folder;
|
|
57
|
+
NAMES.set(path, name);
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
// The products to tag by: each repo's name as people say it, capitalised.
|
|
61
|
+
function productNames(map = {}) {
|
|
62
|
+
const out = new Set();
|
|
63
|
+
for (const [n, p] of Object.entries(map)) { if (n === MARKETING) continue; const d = displayName(p, n); if (d) out.add(d.charAt(0).toUpperCase() + d.slice(1)); }
|
|
64
|
+
return [...out];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- product tags -------------------------------------------------------------------
|
|
68
|
+
// "[Dailify] Write the launch post": the tag as written. Without one, the first product
|
|
69
|
+
// its words name as it's written (capitalised: "Dailify" in a sentence, not "server").
|
|
70
|
+
const TAG = /^\s*\[([^\]\n]{2,40})\]\s*/;
|
|
71
|
+
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
|
+
function productOf(text, names = []) {
|
|
73
|
+
const t = String(text || ""), m = t.match(TAG); if (m) return m[1].trim();
|
|
74
|
+
let best = "", at = Infinity;
|
|
75
|
+
for (const n of names) {
|
|
76
|
+
const s = String(n || "").trim(); if (s.length < 3 || !/^[A-Z0-9]/.test(s)) continue;
|
|
77
|
+
const r = new RegExp(`(^|[^A-Za-z0-9])${reEsc(s)}(?![A-Za-z0-9])`).exec(t);
|
|
78
|
+
if (r && r.index < at) { at = r.index; best = s; }
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
const untagged = (text) => String(text || "").replace(TAG, "");
|
|
83
|
+
// A task's text with its product's tag first (one tag: a new one replaces the old).
|
|
84
|
+
function tagged(text, product) {
|
|
85
|
+
const p = String(product || "").replace(/[[\]\n]/g, "").trim().slice(0, 40), t = untagged(text).trim();
|
|
86
|
+
return p ? `[${p}] ${t}` : t;
|
|
87
|
+
}
|
|
88
|
+
// What counts as marketing in another lane's tasks: posting, launches, campaigns, pricing.
|
|
89
|
+
const MARKETING_WORDS = /\b(marketing|symbiot post|posting|posts? (?:to|on) linkedin|linkedin posts?|social (?:media )?posts?|launch|campaign|audience|newsletter|brand|pricing|4-week test|landing page|drafts to post|demo video)\b/i;
|
|
90
|
+
|
|
91
|
+
// ---- what its agent has drafted -------------------------------------------------------
|
|
92
|
+
// drafts/<product>/<name>.md (two levels down at most), newest first: [{ file, name,
|
|
93
|
+
// product, at }]. Its product: the "product:" line at its top, else its folder's name.
|
|
94
|
+
function draftFiles(dir = MARKETING_DIR, names = [], { max = 30 } = {}) {
|
|
95
|
+
const root = join(dir, DRAFTS), out = [];
|
|
96
|
+
const walk = (d, depth) => {
|
|
97
|
+
let ents = []; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
98
|
+
for (const e of ents) {
|
|
99
|
+
if (e.name.startsWith(".")) continue;
|
|
100
|
+
const p = join(d, e.name);
|
|
101
|
+
if (e.isDirectory()) { if (depth < 2) walk(p, depth + 1); continue; }
|
|
102
|
+
if (!/\.md$/i.test(e.name)) continue;
|
|
103
|
+
let head = "", at = 0; try { head = readFileSync(p, "utf8").slice(0, 600); at = statSync(p).mtimeMs; } catch {}
|
|
104
|
+
const tagLine = (head.match(/^\s*product:\s*(.+)$/im) || [])[1], folder = relative(root, d).split("/")[0] || "";
|
|
105
|
+
const product = (tagLine || "").trim() || names.find((n) => n.toLowerCase() === folder.toLowerCase()) || (folder ? folder.charAt(0).toUpperCase() + folder.slice(1) : "");
|
|
106
|
+
const title = (head.match(/^#\s+(.+)$/m) || [])[1];
|
|
107
|
+
out.push({ file: p, rel: relative(dir, p), name: (title || basename(e.name, ".md").replace(/[-_]+/g, " ")).trim().slice(0, 120), product, at });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(root, 0);
|
|
111
|
+
return out.sort((a, b) => b.at - a.at).slice(0, max);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- its brief ----------------------------------------------------------------------------
|
|
115
|
+
// The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
|
|
116
|
+
// its tasks name and where each one's code and docs are, and its rules.
|
|
117
|
+
function marketingBrief(list = [], { map = {}, names = productNames(map) } = {}) {
|
|
118
|
+
const named = [...new Set(list.map((t) => productOf(t.text, names)).filter(Boolean))];
|
|
119
|
+
// its repo: by folder, or by its name as people say it (Dailify is in CallForge AI)
|
|
120
|
+
const pathOf = (p) => { const k = Object.keys(map).find((n) => n !== MARKETING && map[n] && [n, basename(map[n]), displayName(map[n], n)].some((x) => x.toLowerCase() === p.toLowerCase())); return k ? map[k] : ""; };
|
|
121
|
+
const where = named.map((p) => ({ p, path: pathOf(p) }));
|
|
122
|
+
return ["## This lane: marketing",
|
|
123
|
+
"This lane markets every product the user makes, not one repo's: posts, demo videos and screenshots, launches, landing-page copy, campaigns. This folder is the lane's own (a local git repo, no remote): what you write here is reviewed and approved under Marketing in Symbiot.",
|
|
124
|
+
...(where.length ? ["- **Products in these tasks** (read each one's README and docs for what it is and its end goal):", ...where.map((w) => ` - ${w.p}${w.path ? `: \`${w.path}\`` : " (not one of the user's repos here: ask in QUESTIONS.md where it lives, or hand it over)"}`)] : []),
|
|
125
|
+
`- Write each piece as a \`.md\` under \`${DRAFTS}/<product>/\` (\`${DRAFTS}/dailify/launch-post.md\`), a \`# \` title first, then \`product: <Product>\`, so the Marketing page tags it. Its screenshots and clips go next to it.`,
|
|
126
|
+
"- Every post opens with a strong hook (its first line written for reach), says plainly what the product is, and states its end goal. Where the end goal isn't written down, draft one and flag it in QUESTIONS.md for the user to confirm.",
|
|
127
|
+
"- Never post, publish, schedule or send anything, and never sign in anywhere as the user: they approve each draft under Marketing and post it themselves. A post that waits on their OK is a question in QUESTIONS.md, with what to check first."];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief };
|
package/mind.mjs
CHANGED
|
@@ -24,15 +24,15 @@ import { VERSION, CONFIG_DIR, clipWords } from "./core.mjs";
|
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
25
|
import { handoffCmd, runHandoff } from "./agents.mjs";
|
|
26
26
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
27
|
-
import {
|
|
28
|
-
import { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
27
|
+
import { laneMap } from "./scan.mjs";
|
|
28
|
+
import { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
29
29
|
import { styleOf, styleLine, userStyleLine } from "./adapt.mjs";
|
|
30
30
|
import { knowledgeFor } from "./knowledge.mjs";
|
|
31
31
|
|
|
32
32
|
const MIND_FILE = join(CONFIG_DIR, "mind.json");
|
|
33
33
|
const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
|
|
34
34
|
const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
|
|
35
|
-
const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG =
|
|
35
|
+
const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG = 240, RECALL = 8, LOG_LINES = 6, TURN_CHARS = 1500;
|
|
36
36
|
|
|
37
37
|
// ---- memory ---------------------------------------------------------------------
|
|
38
38
|
function loadMind() {
|
|
@@ -80,10 +80,19 @@ function recall(query, now = Date.now(), d = loadMind(), limit = RECALL) {
|
|
|
80
80
|
}
|
|
81
81
|
const recallText = (nodes) => nodes.map((n) => `- ${n.name} (${n.kind}): ${n.facts.slice(-4).map((f) => f.text).join("; ")}`).join("\n");
|
|
82
82
|
function logTurn(where, role, text, now = Date.now(), d = loadMind()) {
|
|
83
|
-
d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0,
|
|
83
|
+
d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0, TURN_CHARS) }].slice(-MAX_LOG);
|
|
84
84
|
}
|
|
85
85
|
// The last few lines said anywhere else in the app: how the next page knows
|
|
86
86
|
// what you were just talking about.
|
|
87
|
+
// This chat's own last turns, for a chat that doesn't keep its thread itself (Home):
|
|
88
|
+
// without them a "yeah" to "want that?" went to whatever was said elsewhere (a
|
|
89
|
+
// kernel reboot card) and the task they'd agreed to was never made. Home is where
|
|
90
|
+
// people think things through, so it keeps a real conversation: 16 turns, each whole
|
|
91
|
+
// (up to TURN_CHARS), from the last 12 hours.
|
|
92
|
+
const THREAD_TURNS = 16, THREAD_AGE = 12 * 3600000;
|
|
93
|
+
function ownThread(where, d = loadMind(), now = Date.now()) {
|
|
94
|
+
return d.log.filter((l) => l.where === where && now - (l.ts || 0) < THREAD_AGE).slice(-THREAD_TURNS).map((l) => `${l.role === "user" ? "User" : "You"}: ${l.text}`).join("\n");
|
|
95
|
+
}
|
|
87
96
|
function lately(where, d = loadMind(), n = LOG_LINES) {
|
|
88
97
|
return d.log.filter((l) => l.where !== where).slice(-n).map((l) => `- [${l.where}] ${l.role === "user" ? "User" : "You"}: ${l.text.slice(0, 200)}`).join("\n");
|
|
89
98
|
}
|
|
@@ -99,7 +108,7 @@ function forget(id) {
|
|
|
99
108
|
// The brief for a run of its own: the request, what the page showed, what's
|
|
100
109
|
// known, and the rule that anything hard to undo is asked first.
|
|
101
110
|
function actBrief(request, { title = "Symbiot", context = "", known = "", lanes = null, now = Date.now() } = {}) {
|
|
102
|
-
let names = lanes; if (!names) { try { names = Object.keys(
|
|
111
|
+
let names = lanes; if (!names) { try { names = Object.keys(laneMap()); } catch { names = []; } }
|
|
103
112
|
const short = clipWords(request.replace(/\s+/g, " "), 160); // its task line; the whole request is quoted above it
|
|
104
113
|
return `# For your agent: ${title}
|
|
105
114
|
_written by symbiot ${VERSION} · ${new Date(now).toISOString().slice(0, 10)}_
|
|
@@ -153,9 +162,12 @@ function rulesFor(lanes = [], self = "") {
|
|
|
153
162
|
{"reply": "what you say, plain text, brief, no preamble",
|
|
154
163
|
"do": null or {"agent": "what a coding agent should do now: self-contained, with names, links and ids", "repo": "the lane it's for, or empty"} or {"task": "a task for later", "repo": "the lane it's for, or empty"},
|
|
155
164
|
"remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
|
|
156
|
-
When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first,
|
|
157
|
-
Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
|
|
165
|
+
When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, on the Workdesk, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
|
|
166
|
+
Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
|
|
158
167
|
Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
|
|
168
|
+
A short answer (yes, yeah, ok, sure, go, do it, that one, 2) answers what you last said in "This chat so far", never something from elsewhere in the app. If you'd proposed a task or an agent and they agree, do it now in "do", in the lane you named.
|
|
169
|
+
Hash it out first: when they bring an idea rather than a clear ask, sharpen it with them: say what's strong, push back where it's weak, add what's missing, and propose it as a task (naming the lane) once it holds up. "do" stays null until they agree.
|
|
170
|
+
When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through.
|
|
159
171
|
"remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
|
|
160
172
|
}
|
|
161
173
|
// Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
|
|
@@ -193,6 +205,7 @@ function parseReply(raw) {
|
|
|
193
205
|
// what "do" runs here. Gives { reply, did?, remembered }.
|
|
194
206
|
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
|
|
195
207
|
const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
|
|
208
|
+
if (!history) history = ownThread(where, d, now);
|
|
196
209
|
// what it did to answer, to show under the reply (as the app shows an agent's work)
|
|
197
210
|
const steps = [];
|
|
198
211
|
if (hits.length) steps.push(`recalled ${hits.length === 1 ? hits[0].name : hits.length + " things: " + hits.slice(0, 3).map((h) => h.name).join(", ")}`);
|
|
@@ -201,11 +214,11 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
201
214
|
// your knowledge folders (knowledge.mjs): a few short excerpts, each with its file to cite; never examples
|
|
202
215
|
let kn = { text: "", steps: [] }; try { kn = knowledgeFor(question); } catch {}
|
|
203
216
|
steps.push(...kn.steps);
|
|
204
|
-
let lanes = map; if (!lanes) { try { lanes =
|
|
217
|
+
let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
|
|
205
218
|
// how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
|
|
206
219
|
const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
|
|
207
220
|
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
|
|
208
|
-
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
|
|
221
|
+
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app (other chats: a short answer here doesn't reply to these):\n${elsewhere}\n\n` : "") +
|
|
209
222
|
(context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
|
|
210
223
|
const raw = await ask(system, prompt, images.length ? { images } : undefined);
|
|
211
224
|
if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
|
|
@@ -217,8 +230,8 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
217
230
|
if (want && want.agent && act.agent) {
|
|
218
231
|
did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
|
|
219
232
|
reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
|
|
220
|
-
: did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's
|
|
221
|
-
: "\n\n→ Handed to your agent. It's
|
|
233
|
+
: did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's on the Workdesk.`
|
|
234
|
+
: "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
222
235
|
} else if (want && want.task && act.task) {
|
|
223
236
|
did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
|
|
224
237
|
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
@@ -235,6 +248,6 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
235
248
|
return { reply, ...(did ? { did } : {}), remembered, steps };
|
|
236
249
|
}
|
|
237
250
|
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
238
|
-
const addToTasks = (text, repo) => { let map = {}; try { map =
|
|
251
|
+
const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
239
252
|
|
|
240
|
-
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
|
|
253
|
+
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks , ownThread };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.3",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"fonts",
|
|
44
44
|
"mapknn.mjs",
|
|
45
45
|
"guard.mjs",
|
|
46
|
-
"away.mjs"
|
|
46
|
+
"away.mjs",
|
|
47
|
+
"marketing.mjs"
|
|
47
48
|
],
|
|
48
49
|
"engines": {
|
|
49
50
|
"node": ">=18"
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
77
78
|
},
|
|
78
79
|
"scripts": {
|
|
79
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
80
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
80
81
|
"lint": "eslint ."
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
package/reports.mjs
CHANGED
|
@@ -20,10 +20,10 @@ import { createHash } from "node:crypto";
|
|
|
20
20
|
import { CONFIG_DIR } from "./core.mjs";
|
|
21
21
|
import { runFolders, laneOf, runTitle } from "./handback.mjs";
|
|
22
22
|
import { runningHandoff } from "./agents.mjs";
|
|
23
|
-
import {
|
|
23
|
+
import { laneMap } from "./scan.mjs";
|
|
24
24
|
|
|
25
25
|
const SEEN_FILE = join(CONFIG_DIR, "reports.json");
|
|
26
|
-
const NOT_REPORTS = /^(?:TASKS
|
|
26
|
+
const NOT_REPORTS = /^(?:TASKS(?:\.[\w-]+)?|QUESTIONS|ANSWERS|HANDOFF|SKIPPED|CLAUDE|AGENTS)\.md$|^BRIEF/i; // TASKS.next.md too: the tasks held for after a run
|
|
27
27
|
const MAX_READ = 512 * 1024, MAX_LIST = 200;
|
|
28
28
|
const idOf = (file) => createHash("sha1").update(file).digest("hex").slice(0, 12);
|
|
29
29
|
|
|
@@ -40,7 +40,7 @@ function titleOf(text, name) {
|
|
|
40
40
|
// run, size, mtime, new, running }]. `folders`, `running` and `seen` can be given
|
|
41
41
|
// (the tests do).
|
|
42
42
|
function listReports({ map, folders, running = runningHandoff, seen } = {}) {
|
|
43
|
-
if (!folders) { map = map ||
|
|
43
|
+
if (!folders) { map = map || laneMap(); folders = runFolders(map); }
|
|
44
44
|
const s = (seen || loadSeen()).seen, out = [];
|
|
45
45
|
for (const folder of folders) {
|
|
46
46
|
const dir = join(folder, ".symbiot");
|
package/scan.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { VERSION, loadConfig, saveConfig, sh, repoState } from "./core.mjs";
|
|
|
10
10
|
import { detectHandoffs } from "./agents.mjs";
|
|
11
11
|
import { PROVIDERS, resolveProvider } from "./ai.mjs";
|
|
12
12
|
import { mapKnn } from "./mapknn.mjs";
|
|
13
|
+
import { MARKETING, MARKETING_DIR } from "./marketing.mjs";
|
|
13
14
|
|
|
14
15
|
const MAX_COMMITS = 140;
|
|
15
16
|
|
|
@@ -444,7 +445,8 @@ async function buildMapScan() {
|
|
|
444
445
|
if (scanExpired()) break;
|
|
445
446
|
await yieldTick();
|
|
446
447
|
const det = detectFolder(d); folders++; scanTick(det.name);
|
|
447
|
-
|
|
448
|
+
let last = ""; try { last = new Date(statSync(d).mtimeMs).toISOString().slice(0, 10); } catch {} // the Map sorts folders by it, as repos by their last commit
|
|
449
|
+
add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files, last } });
|
|
448
450
|
edges.push({ source: "me", target: "folder:" + d });
|
|
449
451
|
for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
|
|
450
452
|
} }
|
|
@@ -547,6 +549,11 @@ function repoPathMap() {
|
|
|
547
549
|
return byName;
|
|
548
550
|
}
|
|
549
551
|
|
|
552
|
+
// The lanes a task can go to: every repo and project folder, and Marketing, a lane of
|
|
553
|
+
// its own for marketing across products (marketing.mjs). A repo of yours called
|
|
554
|
+
// marketing keeps the name. Counting your repos is repoPathMap's job, not this.
|
|
555
|
+
function laneMap() { const m = repoPathMap(); if (!Object.keys(m).some((n) => n.toLowerCase() === MARKETING)) m[MARKETING] = MARKETING_DIR; return m; }
|
|
556
|
+
|
|
550
557
|
// The SAME repo set the Map uses — reuse its cached scan when present, else do
|
|
551
558
|
// the identical discovery. So week/standup/todo/drift all agree with the Map.
|
|
552
559
|
function discoveredRepos() {
|
|
@@ -554,4 +561,4 @@ function discoveredRepos() {
|
|
|
554
561
|
return findAllRepos();
|
|
555
562
|
}
|
|
556
563
|
|
|
557
|
-
export { SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
|
|
564
|
+
export { laneMap, SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
|
package/server.mjs
CHANGED
|
@@ -14,9 +14,9 @@ import { createServer } from "node:http";
|
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
16
16
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
17
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
|
|
17
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
|
|
18
18
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
19
|
-
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
19
|
+
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap } from "./scan.mjs";
|
|
20
20
|
import { computeDrift } from "./drift.mjs";
|
|
21
21
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
22
22
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
@@ -30,7 +30,8 @@ import { mindState, forget } from "./mind.mjs";
|
|
|
30
30
|
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
31
31
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
32
32
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
33
|
-
import { homeState, homeAsk, homeAnswer, workScene, workGo, firstSteps } from "./home.mjs";
|
|
33
|
+
import { homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, firstSteps, marketingState, marketingGo, marketingTask, moveToMarketing } from "./home.mjs";
|
|
34
|
+
import { MARKETING_DIR } from "./marketing.mjs";
|
|
34
35
|
import { listReports, readReport, markAllRead } from "./reports.mjs";
|
|
35
36
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
36
37
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
@@ -230,7 +231,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
230
231
|
if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
|
|
231
232
|
}
|
|
232
233
|
try {
|
|
233
|
-
if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} ·
|
|
234
|
+
if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, provider: r.provider, line: `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` } : { connected: false }); }
|
|
234
235
|
if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
|
|
235
236
|
if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
|
|
236
237
|
if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
|
|
@@ -255,9 +256,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
255
256
|
if (!ask.length) return json(res, partly(list));
|
|
256
257
|
await learnNpm(ask); return json(res, partly(pendingReview()));
|
|
257
258
|
}
|
|
258
|
-
if (u.pathname === "/api/pending/diff") { const p =
|
|
259
|
-
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([
|
|
260
|
-
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([
|
|
259
|
+
if (u.pathname === "/api/pending/diff") { const p = laneMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
260
|
+
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
|
|
261
|
+
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump, tick: b.tick })); return json(res, approveChanges(repo, { bump: b.bump, tick: b.tick, notes })); }
|
|
261
262
|
if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
|
|
262
263
|
if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
|
|
263
264
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
@@ -277,8 +278,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
277
278
|
if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
|
|
278
279
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
|
|
279
280
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
280
|
-
if (u.pathname === "/api/agent/trust") return json(res, { full: trustFull() });
|
|
281
|
-
if (u.pathname === "/api/agent/trust/set" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); if (b.full) delete cfg.agentTrust; else cfg.agentTrust = "ask"; saveConfig(cfg); return json(res, { full: trustFull() }); }
|
|
281
|
+
if (u.pathname === "/api/agent/trust") return json(res, { full: trustFull(), sandbox: sandboxState() });
|
|
282
|
+
if (u.pathname === "/api/agent/trust/set" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); if (b.full) delete cfg.agentTrust; else cfg.agentTrust = "ask"; saveConfig(cfg); return json(res, { full: trustFull(), sandbox: sandboxState() }); }
|
|
282
283
|
if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
|
|
283
284
|
if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || ""), { force: !!b.force }); return json(res, { opened: !!e && !e.busy && !e.blocked, busy: !!(e && e.busy), auto: !!(e && e.auto), blocked: !!(e && e.blocked), note: (e && e.note) || "", id: (e && e.id) || "" }); }
|
|
284
285
|
if (u.pathname === "/api/setup-local" && req.method === "POST") {
|
|
@@ -348,8 +349,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
348
349
|
const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
|
|
349
350
|
let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
|
|
350
351
|
if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
|
|
351
|
-
|
|
352
|
+
let lane = null; try { lane = marketingState(); } catch {}
|
|
353
|
+
return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t, lane });
|
|
352
354
|
}
|
|
355
|
+
// Marketing's own lane (marketing.mjs): a task for it, tagged with its product; another
|
|
356
|
+
// lane's marketing task moved to it; its agent started; a draft its agent wrote, opened
|
|
357
|
+
if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
|
|
358
|
+
if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
|
|
359
|
+
if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
|
|
360
|
+
if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
|
|
353
361
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
354
362
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
|
|
355
363
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -375,14 +383,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
375
383
|
if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
|
|
376
384
|
if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
|
|
377
385
|
if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
|
|
386
|
+
if (u.pathname === "/api/home/next" && req.method === "POST") { const b = await readBody(req); return json(res, homeNext(b.id)); }
|
|
378
387
|
if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
|
|
379
388
|
if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
|
|
380
389
|
if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
|
|
381
390
|
// Lanes (lanes.mjs): work agents handed to each other, and where it stands.
|
|
382
391
|
if (u.pathname === "/api/lanes") return json(res, lanesState());
|
|
383
392
|
// a parked project (lane) starts no agent runs until it's unparked (agents.mjs parkLane)
|
|
384
|
-
if (u.pathname === "/api/lanes/parked") { const map =
|
|
385
|
-
if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map =
|
|
393
|
+
if (u.pathname === "/api/lanes/parked") { const map = laneMap(), ps = parkedPaths(); return json(res, { repos: Object.keys(map).filter((n) => ps.includes(map[n])), paths: ps }); }
|
|
394
|
+
if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map = laneMap(), p = Object.values(map).includes(String(b.path || "")) ? String(b.path) : map[String(b.repo || "")]; if (!p) return json(res, { error: "That project isn't on this computer." }); return json(res, parkLane(p, b.on !== false)); }
|
|
386
395
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
387
396
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
388
397
|
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
@@ -491,6 +500,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
491
500
|
// an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
|
|
492
501
|
setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
|
|
493
502
|
setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();
|
|
503
|
+
// a finished run's last words, read by your AI for anything left to you that the patterns missed (agents.mjs readLastWords)
|
|
504
|
+
setInterval(() => { readLastWords().catch(() => {}); }, 60000).unref();
|
|
494
505
|
// knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
|
|
495
506
|
const knowTick = () => { try { const r = knowledgeTick(); if (r && (r.read || r.removed || !checksState().at)) runChecks(); } catch {} };
|
|
496
507
|
setTimeout(knowTick, 5000).unref(); setInterval(knowTick, 3 * 60 * 1000).unref();
|