symbiot 0.55.1 → 0.55.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mind.mjs CHANGED
@@ -24,8 +24,8 @@ 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 { repoPathMap } from "./scan.mjs";
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
 
@@ -99,7 +99,7 @@ function forget(id) {
99
99
  // The brief for a run of its own: the request, what the page showed, what's
100
100
  // known, and the rule that anything hard to undo is asked first.
101
101
  function actBrief(request, { title = "Symbiot", context = "", known = "", lanes = null, now = Date.now() } = {}) {
102
- let names = lanes; if (!names) { try { names = Object.keys(repoPathMap()); } catch { names = []; } }
102
+ let names = lanes; if (!names) { try { names = Object.keys(laneMap()); } catch { names = []; } }
103
103
  const short = clipWords(request.replace(/\s+/g, " "), 160); // its task line; the whole request is quoted above it
104
104
  return `# For your agent: ${title}
105
105
  _written by symbiot ${VERSION} · ${new Date(now).toISOString().slice(0, 10)}_
@@ -153,8 +153,8 @@ function rulesFor(lanes = [], self = "") {
153
153
  {"reply": "what you say, plain text, brief, no preamble",
154
154
  "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
155
  "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, in the Agents tab, 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.
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.` : ""}
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, 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.
157
+ 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
158
  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.
159
159
  "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
160
  }
@@ -201,7 +201,7 @@ async function converse({ where, role = "", context = "", history = "", question
201
201
  // your knowledge folders (knowledge.mjs): a few short excerpts, each with its file to cite; never examples
202
202
  let kn = { text: "", steps: [] }; try { kn = knowledgeFor(question); } catch {}
203
203
  steps.push(...kn.steps);
204
- let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
204
+ let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
205
205
  // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
206
206
  const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
207
207
  const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
@@ -217,8 +217,8 @@ async function converse({ where, role = "", context = "", history = "", question
217
217
  if (want && want.agent && act.agent) {
218
218
  did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
219
219
  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 in the Agents tab.`
221
- : "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
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 on the Workdesk.`
221
+ : "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
222
222
  } else if (want && want.task && act.task) {
223
223
  did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
224
224
  reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
@@ -235,6 +235,6 @@ async function converse({ where, role = "", context = "", history = "", question
235
235
  return { reply, ...(did ? { did } : {}), remembered, steps };
236
236
  }
237
237
  // The usual "task" act, for a caller with no lanes of its own to pass.
238
- const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };
238
+ const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
239
239
 
240
240
  export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.55.1",
3
+ "version": "0.55.2",
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/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 { repoPathMap } from "./scan.mjs";
23
+ import { laneMap } from "./scan.mjs";
24
24
 
25
25
  const SEEN_FILE = join(CONFIG_DIR, "reports.json");
26
- const NOT_REPORTS = /^(?:TASKS|QUESTIONS|ANSWERS|HANDOFF|SKIPPED|CLAUDE|AGENTS)\.md$|^BRIEF/i;
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 || repoPathMap(); folders = runFolders(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
- 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 } });
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";
@@ -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 = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(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([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
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([repoPathMap()[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 })); }
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
- return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
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 = repoPathMap(), ps = parkedPaths(); return json(res, { repos: Object.keys(map).filter((n) => ps.includes(map[n])), paths: ps }); }
385
- if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map = repoPathMap(), p = Object.values(map).includes(String(b.path || "")) ? String(b.path) : map[String(b.repo || "")]; if (!p) return json(res, { error: "That project isn't on this computer." }); return json(res, parkLane(p, b.on !== false)); }
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();
package/tasks.mjs CHANGED
@@ -12,7 +12,8 @@ import { userStyleLine } from "./adapt.mjs";
12
12
  import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
13
13
  import { gitDefaultBranch } from "./drift.mjs";
14
14
  import { readRunLog } from "./work.mjs";
15
- import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
15
+ import { repoPathMap, laneMap, openWork, detectRepo } from "./scan.mjs";
16
+ import { MARKETING_DIR, ensureMarketing, marketingBrief } from "./marketing.mjs";
16
17
 
17
18
  // ---- tasks: a persistent checklist (stored by core.mjs) -------------------
18
19
  // One line (TASKS.md has a task per line, and a tick only matches a whole one),
@@ -107,7 +108,7 @@ function applyDrops(t, map) {
107
108
  // Tasks held for an agent that has since finished land here too, and an agent
108
109
  // starts on what's still open in them (one `push --open` started can't do that).
109
110
  function syncTasks() {
110
- const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
111
+ const t = loadTasks(); const map = laneMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
111
112
  const dropped = applyDrops(t, map);
112
113
  for (const x of t) {
113
114
  if (x.archived || x.done || x.review || !x.repo) continue;
@@ -375,7 +376,7 @@ function finishedOffer(path, open) {
375
376
  function pendingReview() {
376
377
  const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
377
378
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
378
- const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
379
+ const map = Object.keys(by).length || sent.length ? laneMap() : {};
379
380
  const am = autoMergeRepos();
380
381
  const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) } : { branch: "", files: [], stat: "" }) }; });
381
382
  for (const repo of sent.sort()) {
@@ -497,7 +498,7 @@ function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArr
497
498
  function approveRepo(repo, opts = {}) {
498
499
  const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
499
500
  if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
500
- const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
501
+ const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
501
502
  const busy = stillWorking(repo, path); if (busy) return busy;
502
503
  const r = shipWithBump(path, uniqueTasks(items.map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
503
504
  if (r.error) return r;
@@ -514,7 +515,7 @@ function approveRepo(repo, opts = {}) {
514
515
  // approved with the changes, as Approve does, so they don't go out again.
515
516
  function approveChanges(repo, opts = {}) {
516
517
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
517
- const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
518
+ const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
518
519
  const busy = stillWorking(repo, path); if (busy) return busy;
519
520
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
520
521
  const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
@@ -532,7 +533,7 @@ function approveChanges(repo, opts = {}) {
532
533
  function sendBack(id) {
533
534
  const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
534
535
  it.review = false; delete it.reviewAt; saveTasks(t);
535
- const path = it.repo && repoPathMap()[it.repo];
536
+ const path = it.repo && laneMap()[it.repo];
536
537
  if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && sameTask(l.replace(/^\s*-\s*\[x\]\s*/i, ""), it.text) ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
537
538
  return it;
538
539
  }
@@ -562,13 +563,14 @@ function buildTasksMd(name, ctx, list) {
562
563
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
563
564
  if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
564
565
  if (ctx.connectors) L.push(`- **Connectors:** ${ctx.connectors}`);
566
+ if (ctx.about && ctx.about.length) L.push("", ...ctx.about);
565
567
  L.push("", "## Tasks");
566
568
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
567
569
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
568
570
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
569
571
  L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
570
572
  L.push(...handoverRules(ctx.lanes || [], name), ...HANDBACK);
571
- L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
573
+ L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Workdesk, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
572
574
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
573
575
  "- Judge the options before you ask. Most people pick the recommended option without weighing the other, and Symbiot works for a whole company (developers, sales, everyone), not one person, so the choice is really yours. Both options must be good routes to the best solution, never filler or one you wouldn't take. Each says in plain words, with no jargon, what it does and what it changes from then on for the project, the people working on it and the company. Recommend the one that's best for, in this order, the company, the people doing the work, then the task's goal. Base that on evidence you can check here (git history, tests, logs, how it's used, the answers so far), not on what's quickest, and give that evidence in the context line in a sentence.",
574
576
  ONLY_YOU,
@@ -582,7 +584,7 @@ function buildTasksMd(name, ctx, list) {
582
584
  return L.join("\n") + "\n";
583
585
  }
584
586
  function pushTasks(filter) {
585
- const byName = repoPathMap(); // from the already-scanned map when there is one
587
+ const byName = laneMap(); // from the already-scanned map when there is one, and Marketing (marketing.mjs)
586
588
  // what an approved Drop/Merge named, or an agent deleted from its brief, never goes out again
587
589
  const all = loadTasks(); let changed = applyRemovals(all) + applyDrops(all, byName);
588
590
  // what an agent ticked since the Tasks tab last looked goes to review first, as
@@ -603,7 +605,7 @@ function pushTasks(filter) {
603
605
  const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
604
606
  for (const name of Object.keys(groups)) {
605
607
  const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
606
- if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
608
+ if (!path || (path === MARKETING_DIR && !ensureMarketing(path))) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
607
609
  try {
608
610
  // Cheap, per-repo signals only — no full drift scan (that can be very slow
609
611
  // on big repos and would block the request).
@@ -619,7 +621,9 @@ function pushTasks(filter) {
619
621
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
620
622
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
621
623
  // held: an agent is still running there, so it lands when that one exits
622
- const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName), connectors }, list));
624
+ // Marketing's brief says what the lane is, the products its tasks name and where they are
625
+ const about = path === MARKETING_DIR ? marketingBrief(list, { map: byName }) : [];
626
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName), connectors, about }, list));
623
627
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
624
628
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
625
629
  }