symbiot 0.58.2 → 0.59.0
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 +23 -0
- package/LICENSE +98 -21
- package/README.md +28 -0
- package/agents.mjs +89 -19
- package/claudesetup.mjs +4 -1
- package/core.mjs +29 -3
- package/guard.mjs +12 -4
- package/headless.mjs +105 -9
- package/home.mjs +8 -6
- package/index.mjs +60 -7
- package/lanes.mjs +9 -5
- package/licence.mjs +144 -0
- package/links.mjs +7 -0
- package/marketing.mjs +86 -11
- package/mind.mjs +96 -20
- package/package.json +6 -4
- package/post.mjs +4 -2
- package/server.mjs +26 -7
- package/tasks.mjs +3 -0
- package/tray.mjs +154 -0
- package/ui.mjs +180 -33
- package/watch.mjs +57 -10
package/marketing.mjs
CHANGED
|
@@ -184,17 +184,61 @@ function inLane(dir, rel) {
|
|
|
184
184
|
const sigOf = (body) => createHash("sha256").update(String(body || "")).digest("hex").slice(0, 16);
|
|
185
185
|
const STATUS = ".symbiot/drafts.json";
|
|
186
186
|
function draftStatuses(dir = MARKETING_DIR) { try { const d = JSON.parse(readFileSync(join(dir, STATUS), "utf8")); return d && typeof d === "object" ? d : {}; } catch { return {}; } }
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
187
|
+
// Posted and superseded are for good: an edit after either doesn't reopen it (approved and
|
|
188
|
+
// skipped are of a text, so a changed post asks again). One its agent marked posted by
|
|
189
|
+
// hand before there was a status for it ({ status: "approved", posted: <ms> }) is posted.
|
|
190
|
+
const FINAL = ["posted", "superseded"];
|
|
191
|
+
const stateOf = (s) => (!s ? "" : s.status === "posted" || s.posted ? "posted" : String(s.status || ""));
|
|
192
|
+
function statusOf(dir, rel, body) {
|
|
193
|
+
const s = draftStatuses(dir)[rel], st = stateOf(s);
|
|
194
|
+
return FINAL.includes(st) ? { ...s, status: st, edited: s.sig !== sigOf(body) } : s && s.sig === sigOf(body) ? s : null;
|
|
195
|
+
}
|
|
196
|
+
// a moment as people read it here: local YYYY-MM-DD HH:MM (what --at takes)
|
|
197
|
+
const day = (ms) => { const d = new Date(ms), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
|
|
198
|
+
// Why a posted or superseded draft can't be approved, skipped or posted again, in a line.
|
|
199
|
+
function finalSay(s) {
|
|
200
|
+
if (stateOf(s) === "posted") return `It's posted already${s.postedOn ? ` on ${s.postedOn}` : ""}${s.posted ? `, ${day(s.posted)}` : ""}${s.url ? ` (${s.url})` : ""}: it can't be approved or posted again.`;
|
|
201
|
+
const by = [].concat(s.by || []);
|
|
202
|
+
return `It was superseded${by.length ? ` by ${by.join(", ")}` : ""}: ${by.length ? "approve that one" : "approve the one that replaced it"} instead.`;
|
|
203
|
+
}
|
|
204
|
+
// A draft's path in the lane, as given: lane-relative, ./-relative, or absolute inside it.
|
|
205
|
+
function draftRel(p, dir = MARKETING_DIR) {
|
|
206
|
+
p = String(p || "").trim(); if (!p.startsWith("/")) return p.replace(/^\.\//, "");
|
|
207
|
+
try { const root = realpathSync(dir), r = relative(root, realpathSync(p)); return r && !r.startsWith("..") ? r : p; } catch { return p; }
|
|
208
|
+
}
|
|
209
|
+
// Approve or skip a draft, mark it posted ({ url, on, at }: its link, where, when; default
|
|
210
|
+
// now on its platform) or superseded ({ by }: the draft or drafts that replace it). Posted
|
|
211
|
+
// and superseded take it off the Marketing orb for good. { ok, status, draft } or { error }.
|
|
212
|
+
function setDraftStatus(rel, status, { dir = MARKETING_DIR, now = Date.now(), url = "", on = "", at = "", by = [] } = {}) {
|
|
213
|
+
rel = draftRel(rel, dir);
|
|
190
214
|
const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
|
|
191
|
-
if (!["approved", "skipped"].includes(status)) return { error: "Approve or skip it." };
|
|
215
|
+
if (!["approved", "skipped", ...FINAL].includes(status)) return { error: "Approve or skip it, or mark it posted or superseded." };
|
|
192
216
|
const d = parseDraft(readFileSync(f, "utf8"));
|
|
193
|
-
if (!d.body) return { error: "That draft has no post in it yet." };
|
|
194
|
-
const all = draftStatuses(dir)
|
|
217
|
+
if (!d.body && status !== "superseded") return { error: "That draft has no post in it yet." };
|
|
218
|
+
const all = draftStatuses(dir), prev = all[rel], was = stateOf(prev);
|
|
219
|
+
// posted is the end of it; superseded can still be marked posted (what went out, said)
|
|
220
|
+
if (was === "posted" && status === "posted") {
|
|
221
|
+
if (!prev.url && /^https?:\/\/\S+$/i.test(String(url || "").trim())) { prev.url = String(url).trim(); try { writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch {} } // its link, said late
|
|
222
|
+
return { ok: true, status, already: true, draft: d, rel, said: finalSay(prev) };
|
|
223
|
+
}
|
|
224
|
+
if (was === "posted" || (was === "superseded" && status !== "posted" && status !== "superseded")) return { error: finalSay(prev) };
|
|
225
|
+
let entry = { status, sig: sigOf(d.body), at: now };
|
|
226
|
+
if (status === "posted") {
|
|
227
|
+
url = String(url || "").trim(); if (url && !/^https?:\/\/\S+$/i.test(url)) return { error: "Its link is the post's web address (https://…)." };
|
|
228
|
+
const when = at ? (typeof at === "number" ? at : Date.parse(String(at).replace(" ", "T"))) : now; if (!Number.isFinite(when)) return { error: `"${at}" isn't a date (YYYY-MM-DD HH:MM).` };
|
|
229
|
+
entry = { ...entry, posted: when, postedOn: String(on || d.platform || "").toLowerCase(), ...(url ? { url } : {}) };
|
|
230
|
+
}
|
|
231
|
+
if (status === "superseded") {
|
|
232
|
+
const list = [].concat(by || []).flatMap((x) => String(x || "").split(",")).map((x) => draftRel(x, dir)).filter(Boolean);
|
|
233
|
+
const bad = list.find((x) => x === rel || !/\.md$/i.test(x) || !inLane(dir, x)); if (bad) return { error: bad === rel ? "A draft can't replace itself." : `${bad} isn't a draft in the lane.` };
|
|
234
|
+
if (list.length) entry.by = [...new Set(list)];
|
|
235
|
+
}
|
|
236
|
+
all[rel] = entry;
|
|
195
237
|
try { mkdirSync(join(dir, ".symbiot"), { recursive: true }); writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
196
|
-
return { ok: true, status, draft: d };
|
|
238
|
+
return { ok: true, status, draft: d, rel };
|
|
197
239
|
}
|
|
240
|
+
// What its agent runs once a post is out (or in the platform's scheduler), so it leaves the orb.
|
|
241
|
+
const postedCmd = (rel) => `node "${CLI}" marketing posted "${rel}" --url <the post's link>`;
|
|
198
242
|
// Everything the Marketing page needs to show a draft as the platform's post: who it's
|
|
199
243
|
// from, the text whole and cut where the feed cuts it, its hashtags, its picture or
|
|
200
244
|
// video (each a file next to it), its notes apart, and whether you approved it.
|
|
@@ -204,13 +248,43 @@ function draftPreview(rel, { dir = MARKETING_DIR, cfg } = {}) {
|
|
|
204
248
|
const media = d.media.map((m) => { const r = join(base, m), ext = m.split(".").pop().toLowerCase(); return inLane(dir, r) && MEDIA_TYPES[ext] ? { rel: r, name: basename(m), kind: MEDIA_TYPES[ext].startsWith("video") ? "video" : "image" } : null; }).filter(Boolean);
|
|
205
249
|
const st = statusOf(dir, rel, d.body);
|
|
206
250
|
return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
|
|
207
|
-
media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0
|
|
251
|
+
media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0,
|
|
252
|
+
...(st && FINAL.includes(st.status) ? { final: finalSay(st), posted: st.posted || 0, postedOn: st.postedOn || "", url: st.url || "", by: [].concat(st.by || []), edited: !!st.edited } : {}) };
|
|
208
253
|
}
|
|
209
254
|
// A picture or video a draft shows, or the avatar in config.profile: { file, type } or null.
|
|
210
255
|
function laneMedia(rel, { dir = MARKETING_DIR, cfg = loadConfig() } = {}) {
|
|
211
256
|
const f = rel === "avatar" ? String(((cfg && cfg.profile) || {}).avatar || "") : inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
|
|
212
257
|
return type && existsSync(f) ? { file: f, type } : null;
|
|
213
258
|
}
|
|
259
|
+
// A post's picture or video, picked (the pick tray, tray.mjs): its `media:` line set to the
|
|
260
|
+
// files, by their path in the lane, written next to the draft's own (`media: tray/home.png`);
|
|
261
|
+
// the post itself untouched. Approved before, it asks for your OK again: what you approved
|
|
262
|
+
// showed another picture. Not on one posted or superseded. { ok, media, reopened } or { error }.
|
|
263
|
+
const MEDIA_LINE = /^\s*(?:media|image|video|picture)\s*:/i;
|
|
264
|
+
function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
|
|
265
|
+
rel = draftRel(rel, dir);
|
|
266
|
+
const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
|
|
267
|
+
const all = draftStatuses(dir), prev = all[rel]; if (FINAL.includes(stateOf(prev))) return { error: finalSay(prev) };
|
|
268
|
+
const media = [];
|
|
269
|
+
for (const m of [].concat(files || []).map((x) => draftRel(x, dir)).filter(Boolean)) {
|
|
270
|
+
const p = inLane(dir, m), type = p && MEDIA_TYPES[p.split(".").pop().toLowerCase()];
|
|
271
|
+
if (!type) return { error: `${m} isn't a picture or video in the lane.` };
|
|
272
|
+
media.push({ name: relative(dirname(f), p), video: type.startsWith("video") });
|
|
273
|
+
}
|
|
274
|
+
if (media.length > 1 && media.some((m) => m.video)) return { error: "A post takes one video, or pictures, not both." };
|
|
275
|
+
const src = readFileSync(f, "utf8"), lines = src.split(/\r?\n/);
|
|
276
|
+
let end = lines.findIndex((l) => /^##\s/.test(l) || /^\s*-{3,}\s*$/.test(l)); if (end < 0) end = lines.length;
|
|
277
|
+
const head = lines.slice(0, end), at = head.findIndex((l) => MEDIA_LINE.test(l)), kept = head.filter((l) => !MEDIA_LINE.test(l));
|
|
278
|
+
// where it goes: where it was, else after the head's last key line (product:, when:…), else the title
|
|
279
|
+
let i = at >= 0 ? head.slice(0, at).filter((l) => !MEDIA_LINE.test(l)).length : -1;
|
|
280
|
+
if (i < 0) { kept.forEach((l, k) => { const m = l.match(/^\s*([a-z]+)\s*:/i); if ((m && HEAD_KEYS[m[1].toLowerCase()]) || (/^#\s/.test(l) && i < 0)) i = k + 1; }); if (i < 0) i = 0; }
|
|
281
|
+
const line = media.length ? `media: ${media.map((m) => m.name).join(", ")}` : "";
|
|
282
|
+
if (head.filter((l) => MEDIA_LINE.test(l)).join("\n") === line) return { ok: true, rel, media: media.map((m) => m.name), reopened: false, same: true };
|
|
283
|
+
if (line) kept.splice(i, 0, line);
|
|
284
|
+
try { writeFileSync(f, [...kept, ...lines.slice(end)].join(src.includes("\r\n") ? "\r\n" : "\n")); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
285
|
+
const reopened = stateOf(prev) === "approved"; if (reopened) { delete all[rel]; try { writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch {} }
|
|
286
|
+
return { ok: true, rel, media: media.map((m) => m.name), reopened };
|
|
287
|
+
}
|
|
214
288
|
|
|
215
289
|
// ---- its brief ----------------------------------------------------------------------------
|
|
216
290
|
// The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
|
|
@@ -226,8 +300,9 @@ function marketingBrief(list = [], { map = {}, names = productNames(map) } = {})
|
|
|
226
300
|
`- 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.`,
|
|
227
301
|
"- 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.",
|
|
228
302
|
`- Each draft holds the post apart from your notes: a \`# \` title, then \`product:\`, \`platform:\` (linkedin), \`when:\` (YYYY-MM-DD HH:MM, if it's scheduled) and \`media:\` (its picture or video, next to it) lines, then \`## Post\` with exactly the text that goes out (its line breaks, its hashtags) and nothing else, then \`## Notes\` for your reasoning, sources and anything for the user. Symbiot shows \`## Post\` as the platform's own preview, and what you post is that text, unchanged.`,
|
|
229
|
-
`- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`), with the approved text and media, then check it's there.`
|
|
230
|
-
+ " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved."
|
|
303
|
+
`- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`, and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
|
|
304
|
+
+ " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved.",
|
|
305
|
+
`- Once a post is out (or in the platform's scheduler), mark it posted with its link: \`node "${CLI}" marketing posted <its file> --url <the post's link>\` (\`--at "YYYY-MM-DD HH:MM"\` for a scheduled one). A post you redo in a new file: mark the old one \`node "${CLI}" marketing superseded <old file> --by <new file>\`. Either takes it off the user's list for good, and Symbiot then refuses to approve or post it again: never edit \`.symbiot/drafts.json\` by hand.`];
|
|
231
306
|
}
|
|
232
307
|
|
|
233
|
-
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia };
|
|
308
|
+
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, postedCmd, MEDIA_TYPES, inLane, day };
|
package/mind.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { VERSION, CONFIG_DIR, clipWords } from "./core.mjs";
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
|
-
import { handoffCmd, runHandoff, isUrgent, urgentFirst } from "./agents.mjs";
|
|
25
|
+
import { handoffCmd, runHandoff, isUrgent, urgentFirst, urgentUndo } from "./agents.mjs";
|
|
26
26
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
27
27
|
import { laneMap } from "./scan.mjs";
|
|
28
28
|
import { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
@@ -150,25 +150,63 @@ function actNow(request, { title, context, known, run = runHandoff, now = Date.n
|
|
|
150
150
|
} catch (e) { return { error: "Couldn't write the brief: " + ((e && e.message) || e) }; }
|
|
151
151
|
const e = run(dir, { force: true });
|
|
152
152
|
if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
|
|
153
|
+
if (e.blocked || e.busy) return { error: `Your agent didn't start: ${e.note || "something held it back"}` }; // never "handed over" when nothing runs
|
|
153
154
|
return { ok: true, job: e.id, dir };
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// ---- one voice --------------------------------------------------------------------
|
|
157
158
|
const IDENTITY = `You are Symbiot, the one assistant in the user's Symbiot app. The Dashboard's cards, the Tasks list and the rest are only places they talk to you: it's all you, and you remember across them (see "What you know" and "Lately, elsewhere in the app").`;
|
|
159
|
+
// The pages a chat can open (Home's: converse({ nav: true })), as the app names them,
|
|
160
|
+
// with what's on each, so a vague "where are my updates" lands somewhere sensible.
|
|
161
|
+
// "lane:<name>" is a lane's own page (its tasks and agent).
|
|
162
|
+
const PAGES = {
|
|
163
|
+
home: ["Home", "what only they can do, what's new on what they watch, agents at work"],
|
|
164
|
+
board: ["Dashboard", "the cards of what they watch (Gmail, GitHub, WhatsApp, LinkedIn…) and what's new on each"],
|
|
165
|
+
tasks: ["Workdesk", "every lane's tasks and agents: what's running, waiting, done for review, agents' questions"],
|
|
166
|
+
reports: ["Reports", "what agents wrote up for them to read (audits, findings, plans)"],
|
|
167
|
+
marketing: ["Marketing", "the marketing lane: drafts of posts to approve, its agent"],
|
|
168
|
+
week: ["Week", "their week, written up from their real work"],
|
|
169
|
+
standup: ["Standup", "a standup from yesterday's work"],
|
|
170
|
+
todo: ["Todo", "TODOs and FIXMEs found in their code"],
|
|
171
|
+
drift: ["Drift", "uncommitted, unpushed and stale work across repos"],
|
|
172
|
+
map: ["Map", "their repos as a map"],
|
|
173
|
+
settings: ["Settings", "the AI, their agent, folders, linked sites, email, voice"],
|
|
174
|
+
archive: ["Archive", "tasks that were done or dropped"],
|
|
175
|
+
};
|
|
176
|
+
// A "go" from the model, checked: { to, label, why?, alts: [{ to, label }] }, or null.
|
|
177
|
+
// A page or a lane that isn't there is dropped, never made up.
|
|
178
|
+
function goOf(g, lanes = []) {
|
|
179
|
+
if (!g) return null;
|
|
180
|
+
const one = (x) => {
|
|
181
|
+
const t = String(x || "").trim().toLowerCase().replace(/^#/, "");
|
|
182
|
+
const m = t.match(/^lane:\s*(.+)$/); if (m) { const n = lanes.find((l) => l.toLowerCase() === m[1].trim()); return n ? { to: "lane:" + n, label: n } : null; }
|
|
183
|
+
const id = PAGES[t] ? t : t === "agents" ? "tasks" : Object.keys(PAGES).find((k) => PAGES[k][0].toLowerCase() === t);
|
|
184
|
+
if (id) return { to: id, label: PAGES[id][0] };
|
|
185
|
+
const n = lanes.find((l) => l.toLowerCase() === t); return n ? { to: "lane:" + n, label: n } : null;
|
|
186
|
+
};
|
|
187
|
+
const to = one(typeof g === "string" ? g : g.to); if (!to) return null;
|
|
188
|
+
const why = typeof g === "object" ? String(g.why || "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
189
|
+
const alts = (typeof g === "object" && Array.isArray(g.alts) ? g.alts : []).map(one).filter((a) => a && a.to !== to.to).filter((a, i, l) => l.findIndex((b) => b.to === a.to) === i).slice(0, 2);
|
|
190
|
+
return { ...to, ...(why ? { why } : {}), alts };
|
|
191
|
+
}
|
|
192
|
+
const navRules = () => `
|
|
193
|
+
"go": null, or a page to open now: {"to": "${Object.keys(PAGES).join("|")}|lane:<a lane>", "why": "", "alts": []}. Pages: ${Object.entries(PAGES).map(([k, [n, what]]) => `${k} (${n}: ${what})`).join("; ")}.
|
|
194
|
+
You can open any of these pages: when they ask to go somewhere (go to, take me to, navigate to, open, show me, where's), open it with "go", and never say you can't switch pages. A clear ask ("take me to reports") just goes: "why" empty, "alts" [], the reply a few words ("Opening Reports."). A vague one ("show me that thing from earlier", "where are my updates"): don't ask which, opening a page is easy to undo. Pick the likeliest from what you know, this chat, other chats lately and what Home shows; "why" says in one short line why you picked it, also said in the reply; "alts" has 1-2 other pages they may have meant (these are your other readings, offered as one tap each).`;
|
|
158
195
|
// lanes: the repos "do" can point at; self: Symbiot's own repo among them, where
|
|
159
|
-
// a flaw in Symbiot itself is fixed.
|
|
160
|
-
function rulesFor(lanes = [], self = "") {
|
|
196
|
+
// a flaw in Symbiot itself is fixed; nav: this chat can open pages ("go").
|
|
197
|
+
function rulesFor(lanes = [], self = "", nav = false) {
|
|
161
198
|
return `Reply with JSON only, no markdown fence:
|
|
162
199
|
{"reply": "what you say, plain text, brief, no preamble",
|
|
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"}
|
|
200
|
+
"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"},${nav ? `
|
|
201
|
+
"go": null or {"to": "…", "why": "…", "alts": ["…"]},` : ""}
|
|
164
202
|
"remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
|
|
165
203
|
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
204
|
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.` : ""}
|
|
167
205
|
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
206
|
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
207
|
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
|
|
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
|
|
208
|
+
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.${nav ? ` Where to go is the exception: see "go".` : ""}
|
|
209
|
+
"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.${nav ? navRules() : ""}`;
|
|
172
210
|
}
|
|
173
211
|
// Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
|
|
174
212
|
function selfLane(map) {
|
|
@@ -180,14 +218,49 @@ const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() ===
|
|
|
180
218
|
// there, the way Send to repos does; anything else, an agent of its own (actNow).
|
|
181
219
|
// Urgent work (agents.mjs isUrgent) goes first: every other lane with work going
|
|
182
220
|
// parks, and the lane's own routine run stops, so this one starts now.
|
|
183
|
-
|
|
221
|
+
// What it says comes from what happened: job (a run started on it), queued (behind a
|
|
222
|
+
// run that really is going there: behind, "urgent" or "routine") or notStarted (its
|
|
223
|
+
// agent didn't start, and why: the task stays in the lane). An urgent one that didn't
|
|
224
|
+
// start takes back the parking it did (urgentUndo): nothing would end to resume it.
|
|
225
|
+
function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, first = urgentFirst, undo = urgentUndo, now = Date.now() } = {}) {
|
|
184
226
|
const lane = laneName(repo, map);
|
|
185
227
|
if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
|
|
186
228
|
const t = add(request, lane); if (t && t.error) return { error: t.error };
|
|
187
229
|
const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
|
|
188
230
|
const urgent = isUrgent(request) ? first(map[lane], { lanes: map }) : null;
|
|
189
|
-
const e = run(map[lane]);
|
|
190
|
-
|
|
231
|
+
const e = run(map[lane]);
|
|
232
|
+
if (!e || (e.blocked && !e.busy)) { if (urgent) { try { undo(map[lane], urgent); } catch {} urgent.parked = []; } }
|
|
233
|
+
if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff.", ...(urgent ? { urgent } : {}) };
|
|
234
|
+
const state = e.busy ? { queued: true, behind: (urgent && urgent.busy) || "routine" } : e.blocked ? { notStarted: e.note || "its agent didn't start." } : { job: e.id };
|
|
235
|
+
return { ok: true, lane, task: t.id, ...state, ...(urgent ? { urgent } : {}) };
|
|
236
|
+
}
|
|
237
|
+
// The line under a handover's reply, from what actIn did (see there).
|
|
238
|
+
function handedLine(did) {
|
|
239
|
+
const lane = did.lane, u = did.urgent, un = u && u.unparked ? ` ${lane} was parked; I unparked it for this.` : "";
|
|
240
|
+
if (did.notStarted) return `\n\nAdded to ${lane}'s tasks, but its agent didn't start: ${did.notStarted.replace(/\.?$/, ".")}${un} It's on the Workdesk.`;
|
|
241
|
+
const parked = u && u.parked.length ? ` Parked till it's done: ${u.parked.join(", ")} (a run already going there finishes first, then they wait). They pick up again in that order once it's done.` : "";
|
|
242
|
+
if (u) {
|
|
243
|
+
const how = did.job ? (u.stopped ? `, which stopped what it was on (its changes stay) and started on this now` : ", which started on it now")
|
|
244
|
+
: did.behind === "urgent" ? ` (it's on other urgent work right now, and takes this next)`
|
|
245
|
+
: u.stopped ? `, which is stopping what it was on (its changes stay) to start on this` : ` (it starts once the run there now finishes)`;
|
|
246
|
+
return `\n\nUrgent, so it goes first: handed to ${lane}'s agent${how}.${un}${parked || (did.job ? " Nothing else was running to park." : "")} It's on the Workdesk.`;
|
|
247
|
+
}
|
|
248
|
+
return `\n\nHanded to ${lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ", which started on it now"}.${un} It's on the Workdesk.`;
|
|
249
|
+
}
|
|
250
|
+
// The same line, read aloud: a short natural phrase, never the task or the agent's brief
|
|
251
|
+
// (the screen has those), and nothing when the reply already said it ("done, it's
|
|
252
|
+
// queued in symbiot"). Reported 2026-10-09: "right arrow added to symbiote tasks: …".
|
|
253
|
+
const SAID_TASK = /\b(add(ed|ing)?|queued?|put it|noted|on (the|your|its|their) (list|tasks|workdesk)|in (the |your |its )?tasks)\b/i;
|
|
254
|
+
const SAID_AGENT = /\b(hand(ed|ing)? (it |this |that )?(to|over|off)|queued?|on it|started|starting|kicked off|(agent|it)('s| is) (on|working|looking|fixing|doing))\b/i;
|
|
255
|
+
function spokenLine(did, reply = "") {
|
|
256
|
+
if (!did) return "";
|
|
257
|
+
if (did.error) return did.kind === "agent" ? "I couldn't hand it to an agent. Why is on screen." : "I couldn't add the task. Why is on screen.";
|
|
258
|
+
if (did.kind === "task") return SAID_TASK.test(reply) ? "" : did.lane ? `I've added it to ${did.lane}'s tasks.` : "I've added it to your tasks, but not to a project yet.";
|
|
259
|
+
if (did.notStarted) return `I've added it to ${did.lane}'s tasks, but its agent didn't start. It's on the Workdesk.`;
|
|
260
|
+
if (SAID_AGENT.test(reply)) return "";
|
|
261
|
+
if (!did.lane) return "I've handed it to an agent.";
|
|
262
|
+
if (did.urgent) return `It's urgent, so ${did.lane}'s agent does it first.`;
|
|
263
|
+
return did.queued ? `I've handed it to the ${did.lane} agent. It starts once its current run finishes.` : `I've handed it to the ${did.lane} agent.`;
|
|
191
264
|
}
|
|
192
265
|
// For later: on the list, in a repo's lane if it names one Symbiot knows.
|
|
193
266
|
function taskIn(text, repo, { map = {}, add = addTask } = {}) {
|
|
@@ -206,7 +279,7 @@ function parseReply(raw) {
|
|
|
206
279
|
// what this page is for; context: what the page shows; history: this chat's
|
|
207
280
|
// own last turns. act: { agent(request) -> result, task(text, repo) -> result },
|
|
208
281
|
// what "do" runs here. Gives { reply, did?, remembered }.
|
|
209
|
-
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
|
|
282
|
+
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [], nav = false }) {
|
|
210
283
|
const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
|
|
211
284
|
if (!history) history = ownThread(where, d, now);
|
|
212
285
|
// what it did to answer, to show under the reply (as the app shows an agent's work)
|
|
@@ -220,38 +293,41 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
220
293
|
let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
|
|
221
294
|
// how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
|
|
222
295
|
const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
|
|
223
|
-
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
|
|
296
|
+
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes), nav)}${voice ? "\n" + voice : ""}`;
|
|
224
297
|
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` : "") +
|
|
225
298
|
(context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
|
|
226
299
|
const raw = await ask(system, prompt, images.length ? { images } : undefined);
|
|
227
300
|
if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
|
|
228
301
|
const j = parseReply(raw);
|
|
229
302
|
let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
|
|
230
|
-
const
|
|
303
|
+
const said = reply;
|
|
304
|
+
const want = j.do && typeof j.do === "object" ? j.do : null, go = nav ? goOf(j.go, Object.keys(lanes)) : null;
|
|
231
305
|
// say where it actually went, from what happened, not from what the model meant
|
|
232
306
|
const repo = want ? String(want.repo || "") : "";
|
|
233
307
|
if (want && want.agent && act.agent) {
|
|
234
308
|
did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
|
|
235
309
|
reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
|
|
236
|
-
: did.lane
|
|
237
|
-
:
|
|
238
|
-
: "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
310
|
+
: did.lane ? handedLine(did)
|
|
311
|
+
: "\n\nHanded to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
239
312
|
} else if (want && want.task && act.task) {
|
|
240
313
|
did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
|
|
241
314
|
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
242
|
-
: did.lane ? `\n\
|
|
243
|
-
: `\n\
|
|
315
|
+
: did.lane ? `\n\nAdded to ${did.lane}'s tasks: ${did.text}`
|
|
316
|
+
: `\n\nAdded to your tasks, but not to a repo${repo ? ` (there's no lane called ${repo})` : ""}, so no agent will pick it up until it has one: ${did.text}`;
|
|
244
317
|
}
|
|
318
|
+
// what's read aloud: the reply, then the line under it as a short phrase (spokenLine)
|
|
319
|
+
const line = spokenLine(did, said), spoken = line ? `${said} ${line}` : said;
|
|
245
320
|
// read again: another chat may have written while the model answered
|
|
246
321
|
const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
|
|
247
322
|
logTurn(where, "user", question, now, d2); logTurn(where, "ai", reply, now, d2);
|
|
248
323
|
saveMind(d2);
|
|
249
324
|
if (voice) steps.push("matched how you talk");
|
|
250
|
-
if (did && !did.error) steps.push(did.kind === "agent" ? (did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
|
|
325
|
+
if (did && !did.error) steps.push(did.kind === "agent" ? (did.notStarted ? `added a task to ${did.lane}: its agent didn't start` : did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
|
|
251
326
|
if (remembered) steps.push(`remembered ${remembered} new thing${remembered > 1 ? "s" : ""}`);
|
|
252
|
-
|
|
327
|
+
if (go) steps.push(`opened ${go.label}`);
|
|
328
|
+
return { reply, spoken, ...(did ? { did } : {}), ...(go ? { go } : {}), remembered, steps };
|
|
253
329
|
}
|
|
254
330
|
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
255
331
|
const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
256
332
|
|
|
257
|
-
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks , ownThread };
|
|
333
|
+
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, spokenLine, goOf, PAGES, addToTasks , ownThread };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
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": {
|
|
@@ -45,13 +45,15 @@
|
|
|
45
45
|
"guard.mjs",
|
|
46
46
|
"away.mjs",
|
|
47
47
|
"marketing.mjs",
|
|
48
|
+
"tray.mjs",
|
|
48
49
|
"postinstall.mjs",
|
|
49
50
|
"icon.svg",
|
|
50
51
|
"scanworker.mjs",
|
|
51
52
|
"estimate.mjs",
|
|
52
53
|
"icon.ico",
|
|
53
54
|
"claudesetup.mjs",
|
|
54
|
-
"voice.mjs"
|
|
55
|
+
"voice.mjs",
|
|
56
|
+
"licence.mjs"
|
|
55
57
|
],
|
|
56
58
|
"engines": {
|
|
57
59
|
"node": ">=18"
|
|
@@ -71,7 +73,7 @@
|
|
|
71
73
|
"cli"
|
|
72
74
|
],
|
|
73
75
|
"author": "Garth White <garth@ghost-ai.co.za>",
|
|
74
|
-
"license": "
|
|
76
|
+
"license": "Elastic-2.0",
|
|
75
77
|
"repository": {
|
|
76
78
|
"type": "git",
|
|
77
79
|
"url": "git+https://github.com/GarthGhostai/symbiot.git"
|
|
@@ -84,7 +86,7 @@
|
|
|
84
86
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
85
87
|
},
|
|
86
88
|
"scripts": {
|
|
87
|
-
"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/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
89
|
+
"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/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
88
90
|
"lint": "eslint .",
|
|
89
91
|
"postinstall": "node postinstall.mjs"
|
|
90
92
|
},
|
package/post.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { resolveProvider, write } from "./ai.mjs";
|
|
|
33
33
|
import { commits, discoveredRepos, laneMap } from "./scan.mjs";
|
|
34
34
|
import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
|
|
35
35
|
import { runHandoff, runningHandoff } from "./agents.mjs";
|
|
36
|
-
import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames } from "./marketing.mjs";
|
|
36
|
+
import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames, draftStatuses, postedCmd } from "./marketing.mjs";
|
|
37
37
|
|
|
38
38
|
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") };
|
|
39
39
|
const PLATFORM = "linkedin";
|
|
@@ -321,6 +321,8 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
|
|
|
321
321
|
if (!ensureMarketing(dir)) return { error: "Couldn't make Marketing's folder." };
|
|
322
322
|
if (!names) { try { names = productNames(laneMap()); } catch { names = []; } }
|
|
323
323
|
const product = productOf(p.text, names), folder = (product.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "linkedin"), rel = `drafts/${folder}/post-${p.id}.md`;
|
|
324
|
+
// posted already (its agent marked it): never written over and handed out a second time
|
|
325
|
+
const was = draftStatuses(dir)[rel]; if (was && (was.status === "posted" || was.posted)) return { error: `It's posted already (${rel}): it isn't posted twice.` };
|
|
324
326
|
const media = [];
|
|
325
327
|
try {
|
|
326
328
|
mkdirSync(join(dir, "drafts", folder), { recursive: true });
|
|
@@ -330,7 +332,7 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
|
|
|
330
332
|
const st = setDraftStatus(rel, "approved", { dir, now }); if (st.error) return st;
|
|
331
333
|
try {
|
|
332
334
|
const f = join(dir, ".symbiot", "ANSWERS.md"), had = existsSync(f) ? readFileSync(f, "utf8") : "# Answers\n";
|
|
333
|
-
writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
335
|
+
writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, mark it posted (\`${postedCmd(rel)}\`), and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
334
336
|
} catch (e) { return { error: "Couldn't tell Marketing's agent: " + ((e && e.message) || e) }; }
|
|
335
337
|
if (running(dir)) return { rel, queued: true, said: "Approved. Marketing's agent posts it on LinkedIn once the run there now finishes." };
|
|
336
338
|
const e = run(dir, { force: true });
|
package/server.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (ui.mjs) and its /api routes, each a thin call into the module that does the
|
|
3
3
|
// work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
|
|
4
4
|
// listens on your network separately, only for that: phone.mjs.)
|
|
5
|
+
import { licenceState, setKey, clearKey, can, refreshRevoked, FREE } from "./licence.mjs";
|
|
5
6
|
import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
|
|
6
7
|
import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
|
|
7
8
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -15,7 +16,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
|
|
|
15
16
|
import { createServer } from "node:http";
|
|
16
17
|
import { randomBytes } from "node:crypto";
|
|
17
18
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
18
|
-
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
19
|
+
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, moveSecrets, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
19
20
|
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
|
|
20
21
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
21
22
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE, reposState } from "./scan.mjs";
|
|
@@ -23,7 +24,7 @@ import { computeDrift } from "./drift.mjs";
|
|
|
23
24
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
24
25
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
25
26
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
26
|
-
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
27
|
+
import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
27
28
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
|
|
28
29
|
import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
29
30
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
@@ -33,7 +34,8 @@ import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
|
33
34
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
34
35
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
35
36
|
import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
|
|
36
|
-
import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName } from "./marketing.mjs";
|
|
37
|
+
import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
|
|
38
|
+
import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
|
|
37
39
|
import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
|
|
38
40
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
39
41
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
@@ -151,6 +153,8 @@ const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_
|
|
|
151
153
|
const SANDBOX = !!process.env.SYMBIOT_SANDBOX;
|
|
152
154
|
// the installed app (symbiot-desktop): it updates itself and made its own shortcuts
|
|
153
155
|
const DESKTOP = process.env.SYMBIOT_DESKTOP === "1"; // symbiot app --fresh (sandbox.mjs)
|
|
156
|
+
// the licence as Settings shows it: the plan, and Free's 3 projects
|
|
157
|
+
function licenceView() { const st = licenceState(); const L = loadConfig().licence || {}; return { ...st, projects: (L.projects || []).map((p) => ({ path: p, name: p.split(/[\\/]/).pop() })), free: FREE }; } // symbiot app --fresh (sandbox.mjs)
|
|
154
158
|
const FIRST_RUN = !existsSync(CONFIG_PATH); // no config yet when Symbiot started: a brand-new install
|
|
155
159
|
function openApp(url) {
|
|
156
160
|
try {
|
|
@@ -204,6 +208,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
204
208
|
const FRESH = !SANDBOX && !existsSync(CONFIG_PATH);
|
|
205
209
|
// Stable token + port so the URL survives a restart — the open tab can
|
|
206
210
|
// reconnect and auto-reload itself instead of you closing and reopening it.
|
|
211
|
+
try { moveSecrets(); } catch {} // keys from before secrets.json: out of config.json, where a run could read them
|
|
207
212
|
const cfg0 = loadConfig();
|
|
208
213
|
let TOKEN = cfg0.appToken;
|
|
209
214
|
if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
|
|
@@ -236,6 +241,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
236
241
|
if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
|
|
237
242
|
// your projects: kept, and found again off the main thread, so nothing waits on a search
|
|
238
243
|
setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
|
|
244
|
+
if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
|
|
239
245
|
{ const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
|
|
240
246
|
let UPDATING = null; // an update in flight: { target, attempt, retrying? }
|
|
241
247
|
let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
|
|
@@ -360,6 +366,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
360
366
|
if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
|
|
361
367
|
if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
|
|
362
368
|
if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
|
|
369
|
+
// upload: a file of yours to the page's file box, asked like press and type
|
|
370
|
+
if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true }))); }
|
|
363
371
|
if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
|
|
364
372
|
if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
|
|
365
373
|
if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
|
|
@@ -386,15 +394,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
386
394
|
}
|
|
387
395
|
// Marketing's own lane (marketing.mjs): a task for it, tagged with its product; another
|
|
388
396
|
// lane's marketing task moved to it; its agent started; a draft its agent wrote, opened
|
|
389
|
-
if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
|
|
390
|
-
if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
|
|
391
|
-
if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
|
|
397
|
+
if (u.pathname === "/api/marketing/task" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
|
|
398
|
+
if (u.pathname === "/api/marketing/move" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
|
|
399
|
+
if (u.pathname === "/api/marketing/go" && req.method === "POST") { const g = can("marketing"); return json(res, g.ok ? marketingGo() : { error: g.why, pro: true }); }
|
|
392
400
|
// a draft as the post it will be (its preview, notes apart), its picture or video, and
|
|
393
401
|
// your Approve or Skip on it: approved, its agent posts it through Symbiot's browser
|
|
394
402
|
if (u.pathname === "/api/marketing/draft") return json(res, draftPreview(String(u.searchParams.get("rel") || "")));
|
|
395
403
|
if (u.pathname === "/api/marketing/media") { const m = laneMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=300" }); res.end(readFileSync(m.file)); return; }
|
|
396
|
-
if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
|
|
404
|
+
if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
|
|
397
405
|
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." }); }
|
|
406
|
+
// the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
|
|
407
|
+
// a tray), a box switched on or off and the capture blurred again, a capture as a post's media
|
|
408
|
+
if (u.pathname === "/api/marketing/tray") return json(res, { trays: trays() });
|
|
409
|
+
if (u.pathname === "/api/marketing/tray/media") { const m = trayMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=86400" }); res.end(readFileSync(m.file)); return; }
|
|
410
|
+
if (u.pathname === "/api/marketing/tray/blur" && req.method === "POST") { const b = await readBody(req), r = setBlur(String(b.tray || ""), String(b.name || ""), b.id, !!b.on); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
|
|
411
|
+
if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); }
|
|
398
412
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
399
413
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
|
|
400
414
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -501,6 +515,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
501
515
|
if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
|
|
502
516
|
if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
|
|
503
517
|
if (u.pathname === "/api/claude/code" && req.method === "POST") { const b = await readBody(req); return json(res, sendClaudeCode(b.code)); }
|
|
518
|
+
// Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, a Free project slot freed
|
|
519
|
+
if (u.pathname === "/api/licence") return json(res, licenceView());
|
|
520
|
+
if (u.pathname === "/api/licence/key" && req.method === "POST") { const b = await readBody(req); const r = setKey(b.key); return json(res, r.error ? r : licenceView()); }
|
|
521
|
+
if (u.pathname === "/api/licence/clear" && req.method === "POST") { clearKey(); return json(res, licenceView()); }
|
|
522
|
+
if (u.pathname === "/api/licence/project/free" && req.method === "POST") { const b = await readBody(req); const c = loadConfig(); const L = c.licence || {}; L.projects = (L.projects || []).filter((p) => p !== String(b.path || "")); c.licence = L; saveConfig(c); return json(res, licenceView()); }
|
|
504
523
|
if (u.pathname === "/api/update" && req.method === "POST" && DESKTOP) return json(res, { error: "This Symbiot updates with its app." });
|
|
505
524
|
if (u.pathname === "/api/update" && req.method === "POST") {
|
|
506
525
|
// Install the exact newest version (see updateCmd), then relaunch this
|
package/tasks.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// each repo gets (.symbiot/TASKS.md), what the agent ticked (review), and
|
|
3
3
|
// Approve: branch, commit, push, PR, and the version bump and npm release facts
|
|
4
4
|
// it shows.
|
|
5
|
+
import { can } from "./licence.mjs";
|
|
5
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
|
|
@@ -558,6 +559,7 @@ async function shipChanges(path, texts, opts = {}) {
|
|
|
558
559
|
function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
|
|
559
560
|
function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
|
|
560
561
|
async function approveRepo(repo, opts = {}) {
|
|
562
|
+
const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true }; // Pro (licence.mjs); the work stays on its branch
|
|
561
563
|
const waiting = (t) => t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
|
|
562
564
|
const shipped = waiting(loadTasks());
|
|
563
565
|
if (!shipped.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
|
|
@@ -578,6 +580,7 @@ async function approveRepo(repo, opts = {}) {
|
|
|
578
580
|
// repo's open tasks the changes finished (the run didn't tick them): they're
|
|
579
581
|
// approved with the changes, as Approve does, so they don't go out again.
|
|
580
582
|
async function approveChanges(repo, opts = {}) {
|
|
583
|
+
const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true };
|
|
581
584
|
if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return await approveRepo(repo, opts);
|
|
582
585
|
const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
|
|
583
586
|
const busy = stillWorking(repo, path); if (busy) return busy;
|