symbiot 0.44.0 → 0.44.1
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/agents.mjs +20 -3
- package/mind.mjs +48 -13
- package/package.json +1 -1
- package/server.mjs +3 -2
- package/tasks.mjs +48 -7
- package/ui.mjs +14 -4
- package/watch.mjs +6 -3
- package/writeups.mjs +7 -10
package/agents.mjs
CHANGED
|
@@ -495,12 +495,29 @@ function suggestionTarget(s) {
|
|
|
495
495
|
// The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each with
|
|
496
496
|
// the repo whose tasks it goes to (this one unless it names another) and marked
|
|
497
497
|
// if it's already on that repo's task list, in these words or nearly (sameTask).
|
|
498
|
+
// Ideas the user turned down (Skip) stay in .symbiot/SKIPPED.md, a file like
|
|
499
|
+
// ANSWERS.md, so they leave the list and the brief tells the next run not to
|
|
500
|
+
// suggest them again.
|
|
501
|
+
const SKIPPED = "SKIPPED.md";
|
|
502
|
+
const skippedIdeas = (path) => [...readSymbiot(path, SKIPPED).matchAll(/^-\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
503
|
+
function skipIdea(path, text) {
|
|
504
|
+
path = String(path || ""); text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
|
|
505
|
+
if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
|
|
506
|
+
if (!text) return { error: "No idea to skip." };
|
|
507
|
+
const f = join(path, ".symbiot", SKIPPED);
|
|
508
|
+
if (!skippedIdeas(path).some((s) => sameTask(s, text))) {
|
|
509
|
+
try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, (existsSync(f) ? readFileSync(f, "utf8").replace(/\n*$/, "\n") : "# Ideas the user turned down\nDon't suggest these again.\n\n") + `- ${text}\n`); }
|
|
510
|
+
catch (e) { return { error: String((e && e.message) || e) }; }
|
|
511
|
+
}
|
|
512
|
+
return { ok: true };
|
|
513
|
+
}
|
|
498
514
|
function agentQuestions(path, repo) {
|
|
499
515
|
const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
|
|
500
516
|
const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
|
|
501
517
|
const open = p.questions.filter((x) => !done.has(qKey(x.q)));
|
|
502
|
-
const tasks = p.suggestions.length ? loadTasks() : [];
|
|
503
|
-
const ideas = p.suggestions.map(suggestionTarget).
|
|
518
|
+
const tasks = p.suggestions.length ? loadTasks() : [], skipped = p.suggestions.length ? skippedIdeas(path) : [];
|
|
519
|
+
const ideas = p.suggestions.map(suggestionTarget).filter(({ text }) => !skipped.some((s) => sameTask(s, text)))
|
|
520
|
+
.map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
|
|
504
521
|
return {
|
|
505
522
|
questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
|
|
506
523
|
suggestions: ideas, ideasShown: IDEAS_SHOWN,
|
|
@@ -557,4 +574,4 @@ function agentsList() {
|
|
|
557
574
|
}).concat(earlierRuns());
|
|
558
575
|
}
|
|
559
576
|
|
|
560
|
-
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
|
|
577
|
+
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
|
package/mind.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { randomBytes } from "node:crypto";
|
|
|
23
23
|
import { VERSION, CONFIG_DIR } from "./core.mjs";
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
25
|
import { handoffCmd, runHandoff } from "./agents.mjs";
|
|
26
|
-
import { addTask } from "./tasks.mjs";
|
|
26
|
+
import { addTask, pushTasks } from "./tasks.mjs";
|
|
27
27
|
import { repoPathMap } from "./scan.mjs";
|
|
28
28
|
import { OPS, handoverRules, ONLY_YOU } from "./handover.mjs";
|
|
29
29
|
|
|
@@ -141,11 +141,39 @@ function actNow(request, { title, context, known, run = runHandoff, now = Date.n
|
|
|
141
141
|
|
|
142
142
|
// ---- one voice --------------------------------------------------------------------
|
|
143
143
|
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").`;
|
|
144
|
-
|
|
144
|
+
// lanes: the repos "do" can point at; self: Symbiot's own repo among them, where
|
|
145
|
+
// a flaw in Symbiot itself is fixed.
|
|
146
|
+
function rulesFor(lanes = [], self = "") {
|
|
147
|
+
return `Reply with JSON only, no markdown fence:
|
|
145
148
|
{"reply": "what you say, plain text, brief, no preamble",
|
|
146
|
-
"do": null or {"agent": "what
|
|
149
|
+
"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"},
|
|
147
150
|
"remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
|
|
148
|
-
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"
|
|
151
|
+
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.
|
|
152
|
+
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.` : ""}
|
|
153
|
+
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.
|
|
154
|
+
"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.`;
|
|
155
|
+
}
|
|
156
|
+
// Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
|
|
157
|
+
function selfLane(map) {
|
|
158
|
+
for (const [n, p] of Object.entries(map)) { try { if (JSON.parse(readFileSync(join(p, "package.json"), "utf8")).name === "symbiot") return n; } catch {} }
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() === String(repo || "").trim().toLowerCase()) || "";
|
|
162
|
+
// Do it, in the right lane: a repo's work joins its tasks and its agent starts
|
|
163
|
+
// there, the way Send to repos does; anything else, an agent of its own (actNow).
|
|
164
|
+
function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, now = Date.now() } = {}) {
|
|
165
|
+
const lane = laneName(repo, map);
|
|
166
|
+
if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
|
|
167
|
+
const t = add(request, lane); if (t && t.error) return { error: t.error };
|
|
168
|
+
const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
|
|
169
|
+
const e = run(map[lane]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
|
|
170
|
+
return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
|
|
171
|
+
}
|
|
172
|
+
// For later: on the list, in a repo's lane if it names one Symbiot knows.
|
|
173
|
+
function taskIn(text, repo, { map = {}, add = addTask } = {}) {
|
|
174
|
+
const lane = laneName(repo, map), r = add(text, lane);
|
|
175
|
+
return r && r.error ? { error: r.error } : { ok: true, id: r.id, lane };
|
|
176
|
+
}
|
|
149
177
|
|
|
150
178
|
function parseReply(raw) {
|
|
151
179
|
const t = String(raw || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
|
|
@@ -158,9 +186,10 @@ function parseReply(raw) {
|
|
|
158
186
|
// what this page is for; context: what the page shows; history: this chat's
|
|
159
187
|
// own last turns. act: { agent(request) -> result, task(text, repo) -> result },
|
|
160
188
|
// what "do" runs here. Gives { reply, did?, remembered }.
|
|
161
|
-
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, now = Date.now() }) {
|
|
189
|
+
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
|
|
162
190
|
const d = loadMind(), known = recallText(recall(question, now, d)), elsewhere = lately(where, d);
|
|
163
|
-
|
|
191
|
+
let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
|
|
192
|
+
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}`;
|
|
164
193
|
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
|
|
165
194
|
(context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
|
|
166
195
|
const raw = await ask(system, prompt);
|
|
@@ -168,12 +197,18 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
168
197
|
const j = parseReply(raw);
|
|
169
198
|
let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
|
|
170
199
|
const want = j.do && typeof j.do === "object" ? j.do : null;
|
|
200
|
+
// say where it actually went, from what happened, not from what the model meant
|
|
201
|
+
const repo = want ? String(want.repo || "") : "";
|
|
171
202
|
if (want && want.agent && act.agent) {
|
|
172
|
-
did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known)) };
|
|
173
|
-
reply += did.error ? `\n\n(I couldn't hand it to
|
|
203
|
+
did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known, repo)) };
|
|
204
|
+
reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
|
|
205
|
+
: 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.`
|
|
206
|
+
: "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
|
|
174
207
|
} else if (want && want.task && act.task) {
|
|
175
|
-
did = { kind: "task", text: String(want.task).slice(0, 300),
|
|
176
|
-
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
208
|
+
did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
|
|
209
|
+
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
210
|
+
: did.lane ? `\n\n→ Added to ${did.lane}'s tasks: ${did.text}`
|
|
211
|
+
: `\n\n→ Added 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}`;
|
|
177
212
|
}
|
|
178
213
|
// read again: another chat may have written while the model answered
|
|
179
214
|
const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
|
|
@@ -181,7 +216,7 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
181
216
|
saveMind(d2);
|
|
182
217
|
return { reply, ...(did ? { did } : {}), remembered };
|
|
183
218
|
}
|
|
184
|
-
// The usual "task" act
|
|
185
|
-
const addToTasks = (text, repo) => {
|
|
219
|
+
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
220
|
+
const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
186
221
|
|
|
187
|
-
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, parseReply, converse, addToTasks };
|
|
222
|
+
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.44.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { createServer } from "node:http";
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
12
12
|
import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
13
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList, startWaiting } from "./agents.mjs";
|
|
13
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } from "./agents.mjs";
|
|
14
14
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
15
15
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
16
16
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -157,7 +157,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
157
157
|
}
|
|
158
158
|
if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
159
159
|
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
|
|
160
|
-
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump })); }
|
|
160
|
+
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump, tick: b.tick })); }
|
|
161
161
|
if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
|
|
162
162
|
if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
|
|
163
163
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
@@ -178,6 +178,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
178
178
|
}
|
|
179
179
|
if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
|
|
180
180
|
if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
|
|
181
|
+
if (u.pathname === "/api/agents/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipIdea(String(b.path || ""), b.text)); }
|
|
181
182
|
if (u.pathname === "/api/mail") return json(res, mailState());
|
|
182
183
|
if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
|
|
183
184
|
if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || since, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
|
package/tasks.mjs
CHANGED
|
@@ -260,8 +260,37 @@ function shipWithBump(path, texts, opts) {
|
|
|
260
260
|
if (r.error) { undo(); return r; }
|
|
261
261
|
return { ...r, bumped: to };
|
|
262
262
|
}
|
|
263
|
+
// What the last run in a folder said when it finished: agent.log after the last
|
|
264
|
+
// run's "=== name time ===" and "$ command" lines.
|
|
265
|
+
function runSummary(path) {
|
|
266
|
+
let log = ""; try { log = readFileSync(join(path, ".symbiot", "agent.log"), "utf8"); } catch { return ""; }
|
|
267
|
+
const at = log.lastIndexOf("\n=== "), rest = at < 0 ? log : log.slice(log.indexOf("\n", at + 1) + 1);
|
|
268
|
+
return rest.replace(/^\$ .*\n?/, "").trim().slice(-8000);
|
|
269
|
+
}
|
|
270
|
+
// Whether a run's summary says it finished a task: one of its lines (a bullet,
|
|
271
|
+
// a paragraph) has most of the task's words, and doesn't say it wasn't done. A
|
|
272
|
+
// guess the user confirms, so it leans to ticking: an unticked task only comes
|
|
273
|
+
// back as a task.
|
|
274
|
+
const NOT_DONE = /\b(didn['’]?t|did not|couldn['’]?t|could not|can['’]?t|cannot|wasn['’]?t|isn['’]?t|haven['’]?t|not (yet|done|finished|ticked|started)|still (open|to do|needs?)|left (it|them|alone)|blocked)\b/i;
|
|
275
|
+
const keyWords = (s) => new Set(taskWords(s).split(" ").filter((w) => w.length > 3).map((w) => w.replace(/s$/, "")));
|
|
276
|
+
function saidFinished(summary, text) {
|
|
277
|
+
const want = keyWords(text); if (want.size < 2) return false;
|
|
278
|
+
return String(summary || "").split(/\n/).some((l) => {
|
|
279
|
+
if (NOT_DONE.test(l)) return false;
|
|
280
|
+
const has = keyWords(l); let n = 0; for (const w of want) if (has.has(w)) n++;
|
|
281
|
+
return n >= Math.min(3, want.size) && n / want.size >= 0.4;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// The open tasks of a repo whose changes are approved without one: Approve
|
|
285
|
+
// offers to tick them, checked when the last run's summary says it finished them,
|
|
286
|
+
// so finished work doesn't go out again as new tasks on the next send.
|
|
287
|
+
function finishedOffer(path, open) {
|
|
288
|
+
const summary = runSummary(path);
|
|
289
|
+
return uniqueTasks(open.map((x) => x.text)).slice(0, 12).map((text) => { const x = open.find((y) => y.text === text); return { id: x.id, text, finished: saidFinished(summary, text) }; });
|
|
290
|
+
}
|
|
263
291
|
// Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
|
|
264
|
-
// uncommitted changes no ticked task covers (untasked: approve them as-is
|
|
292
|
+
// uncommitted changes no ticked task covers (untasked: approve them as-is, with
|
|
293
|
+
// `open`, the repo's open tasks, to tick the ones the run finished).
|
|
265
294
|
// running: an agent is still editing there, so its changes may be half done.
|
|
266
295
|
function pendingReview() {
|
|
267
296
|
const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
|
|
@@ -271,7 +300,9 @@ function pendingReview() {
|
|
|
271
300
|
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: "" }) }; });
|
|
272
301
|
for (const repo of sent.sort()) {
|
|
273
302
|
const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
|
|
274
|
-
const wc = workingChanges(path); if (wc.files.length)
|
|
303
|
+
const wc = workingChanges(path); if (!wc.files.length) continue;
|
|
304
|
+
const open = finishedOffer(path, t.filter((x) => x.repo === repo && !x.done && !x.archived && !x.review));
|
|
305
|
+
out.push({ repo, path, tasks: [], untasked: true, open, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
|
|
275
306
|
}
|
|
276
307
|
return out;
|
|
277
308
|
}
|
|
@@ -389,14 +420,24 @@ function approveRepo(repo, opts = {}) {
|
|
|
389
420
|
}
|
|
390
421
|
// "Approve changes without a task": ship the uncommitted changes even though no
|
|
391
422
|
// ticked task is behind them (a fix the agent made but didn't tick). If tasks are
|
|
392
|
-
// awaiting review in the repo, this is just Approve.
|
|
423
|
+
// awaiting review in the repo, this is just Approve. opts.tick: ids of the
|
|
424
|
+
// repo's open tasks the changes finished (the run didn't tick them): they're
|
|
425
|
+
// approved with the changes, as Approve does, so they don't go out again.
|
|
393
426
|
function approveChanges(repo, opts = {}) {
|
|
394
427
|
if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
|
|
395
428
|
const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
|
|
396
429
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
397
430
|
if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
|
|
398
|
-
const
|
|
399
|
-
|
|
431
|
+
const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
|
|
432
|
+
const ticked = (t) => t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
|
|
433
|
+
const r = shipWithBump(path, uniqueTasks(ticked(loadTasks()).map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
434
|
+
if (r.error) return r;
|
|
435
|
+
// read again: the ship took a while, and the list may have changed meanwhile
|
|
436
|
+
const t = loadTasks(), items = ticked(t), now = Date.now();
|
|
437
|
+
for (const x of items) { x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
|
|
438
|
+
const removed = items.length ? applyRemovals(t) : 0;
|
|
439
|
+
if (items.length) saveTasks(t);
|
|
440
|
+
return { ...r, approved: items.length, ...(removed ? { removed } : {}) };
|
|
400
441
|
}
|
|
401
442
|
// Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
|
|
402
443
|
function sendBack(id) {
|
|
@@ -443,7 +484,7 @@ function buildTasksMd(name, ctx, list) {
|
|
|
443
484
|
ONLY_YOU,
|
|
444
485
|
"- A `👤 You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
|
|
445
486
|
"- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
|
|
446
|
-
`- Ideas, options or follow-ups outside these tasks go under \`## Suggestions\` as \`- \` bullets. The user can add them to their tasks in one click, and often adds every one, so judge them the same way: only ideas you'd recommend, best first, each saying what it changes and why it's worth doing. Symbiot shows them ${IDEAS_SHOWN} at a time.`,
|
|
487
|
+
`- Ideas, options or follow-ups outside these tasks go under \`## Suggestions\` as \`- \` bullets. The user can add them to their tasks in one click, and often adds every one, so judge them the same way: only ideas you'd recommend, best first, each saying what it changes and why it's worth doing. Symbiot shows them ${IDEAS_SHOWN} at a time. Ideas the user turned down are in \`.symbiot/SKIPPED.md\`: don't suggest them again.`,
|
|
447
488
|
"- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
|
|
448
489
|
"- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
|
|
449
490
|
L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
|
|
@@ -481,4 +522,4 @@ function pushTasks(filter) {
|
|
|
481
522
|
return { empty: false, written, unresolved, handoff: handoffCmd() };
|
|
482
523
|
}
|
|
483
524
|
|
|
484
|
-
export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|
|
525
|
+
export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|
package/ui.mjs
CHANGED
|
@@ -484,6 +484,9 @@ list.forEach(function(r){var ch=r.files.length?(r.files.length+" file"+(r.files.
|
|
|
484
484
|
h+="<div class='rcard' data-repo='"+esc(r.repo)+"'"+(r.publishesOnMerge?" data-pom='1'":"")+"><div class='rhead'><b>"+esc(r.repo)+"</b><span class='muted'>"+(r.path?"on "+esc(r.branch||'?')+" · "+ch:"repo not found on disk")+"</span></div>";
|
|
485
485
|
r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>↶</button></div>";});
|
|
486
486
|
if(r.untasked)h+="<div class='muted' style='margin:6px 0'>Uncommitted changes with no ticked task behind them. Check the diff before you approve.</div>";
|
|
487
|
+
// The repo's open tasks: tick the ones these changes finished, and they're approved with them instead of going out again on the next send.
|
|
488
|
+
if(r.untasked&&r.open&&r.open.length){var said=r.open.some(function(o){return o.finished;});h+="<div class='muted' style='margin:6px 0'>Did these changes finish any of "+esc(r.repo)+"'s open tasks? Tick them and they're approved with the changes, so they don't come back as new tasks"+(said?". Ticked already: the run's summary says it finished them.":".")+"</div>";
|
|
489
|
+
r.open.forEach(function(o){h+="<label class='idea'><input type='checkbox' class='tickopen' data-id='"+esc(o.id)+"'"+(o.finished?" checked":"")+"><span>"+esc(o.text)+(o.finished?" <span class='tag'>run says done</span>":"")+"</span></label>";});}
|
|
487
490
|
if(r.running)h+="<div class='muted' style='margin:6px 0'>⏳ Agent still working: its changes may be half done. Approve unlocks when it finishes.</div>";
|
|
488
491
|
// A repo that publishes on merge (ur.npm) is measured from the version npm has,
|
|
489
492
|
// and a bump publishes it once merged; otherwise from the last v* tag, tagged by hand.
|
|
@@ -501,8 +504,12 @@ var amt=card.querySelector('.amtoggle');if(amt)amt.addEventListener('change',fun
|
|
|
501
504
|
var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
|
|
502
505
|
if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
|
|
503
506
|
pre.textContent='Loading...';pre.classList.remove('hidden');sd.textContent='Hide diff';api('/api/pending/diff?repo='+encodeURIComponent(repo)).then(function(d){pre.textContent=(d&&d.diff)||'(no changes)';});});
|
|
504
|
-
var ap=card.querySelector('.approve'),bs=card.querySelector('.bumpsel')
|
|
505
|
-
|
|
507
|
+
var ap=card.querySelector('.approve'),bs=card.querySelector('.bumpsel'),tk=card.querySelectorAll('.tickopen');
|
|
508
|
+
function ticks(){var ids=[];tk.forEach(function(c){if(c.checked)ids.push(c.getAttribute('data-id'));});return ids;}
|
|
509
|
+
function apLabel(){var n=ticks().length;if(ap&&!ap.disabled)ap.innerHTML=n?"Approve changes with "+n+" finished task"+(n>1?"s":"")+" → PR":"Approve changes without a task → PR";}
|
|
510
|
+
tk.forEach(function(c){c.addEventListener('change',apLabel);});if(tk.length)apLabel();
|
|
511
|
+
if(ap)ap.addEventListener('click',function(){var tick=ticks();ap.disabled=true;ap.textContent='Committing & pushing...';
|
|
512
|
+
api(ap.getAttribute('data-untasked')?'/api/pending/approve-changes':'/api/pending/approve',{repo:repo,bump:bs?bs.value:'',tick:tick}).then(function(r){var o=document.getElementById('reviewout');
|
|
506
513
|
if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="<div class='note err'>"+esc(repo)+": "+esc((r&&r.error)||'failed')+"</div>";return;}
|
|
507
514
|
var m="✓ <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
|
|
508
515
|
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
@@ -538,7 +545,7 @@ qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div
|
|
|
538
545
|
(q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
|
|
539
546
|
h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
|
|
540
547
|
h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers & continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
|
|
541
|
-
if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4>💡 Ideas from the agent</h4>";ss.forEach(function(s,i){if(i>=shown)return;h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});
|
|
548
|
+
if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4>💡 Ideas from the agent</h4>";ss.forEach(function(s,i){if(i>=shown)return;h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button><button class='ghost qskip' data-i='"+i+"' title='turn this idea down: the next one moves up, and the agent is told not to suggest it again' style='padding:3px 9px;font-size:12px'>Skip</button>")+"</div>";});
|
|
542
549
|
if(ss.length>shown)h+="<div class='row'><button class='ghost qmore' title='the agent ranks its ideas best first: weigh these two before adding more'>"+(ss.length-shown)+" more idea"+(ss.length-shown>1?"s":"")+", ranked lower</button></div>";}
|
|
543
550
|
return h+"</div>";}
|
|
544
551
|
var IDEASOPEN={};
|
|
@@ -558,7 +565,10 @@ var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
|
|
|
558
565
|
if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
|
|
559
566
|
var more=box.querySelector('.qmore');if(more)more.addEventListener('click',function(){IDEASOPEN[a.path]=true;loadAgents();});
|
|
560
567
|
box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
|
|
561
|
-
|
|
568
|
+
var sk=btn.parentNode.querySelector('.qskip');if(sk)sk.remove();
|
|
569
|
+
api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});
|
|
570
|
+
box.querySelectorAll('.qskip').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
|
|
571
|
+
api('/api/agents/skip',{path:a.path,text:s.text}).then(function(r){if(!r||r.error){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}loadAgents();});});});});}
|
|
562
572
|
// A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
|
|
563
573
|
function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");if(w.cmd)fs=(fs?fs+" or ":"")+"the agent command in Settings → Handoff";
|
|
564
574
|
return "<div class='aq'><h4>⏸ Waiting on your step</h4><div class='qt'>👤 "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
|
package/watch.mjs
CHANGED
|
@@ -31,7 +31,8 @@ import { desktopNotify } from "./desktop.mjs";
|
|
|
31
31
|
import { resolveProvider, write } from "./ai.mjs";
|
|
32
32
|
import { handoffCmd, runHandoff, runningHandoff } from "./agents.mjs";
|
|
33
33
|
import { linksIn, peekLinks, peekLine } from "./peek.mjs";
|
|
34
|
-
import { converse, actNow,
|
|
34
|
+
import { converse, actNow, actIn, taskIn } from "./mind.mjs";
|
|
35
|
+
import { repoPathMap } from "./scan.mjs";
|
|
35
36
|
|
|
36
37
|
const WATCH_FILE = join(CONFIG_DIR, "watch.json");
|
|
37
38
|
const EVERY = [5, 15, 30, 60]; // minutes between reads
|
|
@@ -257,7 +258,7 @@ function cardLinks(w, recent, question) {
|
|
|
257
258
|
const own = hostOf(w.url), host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } };
|
|
258
259
|
return [...linksIn(question), ...recent.flatMap((n) => linksIn(n.text))].filter((u) => host(u) && host(u) !== own);
|
|
259
260
|
}
|
|
260
|
-
async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write, look = peekLinks, run = actNow } = {}) {
|
|
261
|
+
async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write, look = peekLinks, run = actNow, lanes = () => { try { return repoPathMap(); } catch { return {}; } } } = {}) {
|
|
261
262
|
question = String(question || "").trim().slice(0, 2000);
|
|
262
263
|
const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` };
|
|
263
264
|
if (!question) return { error: "empty" };
|
|
@@ -272,7 +273,9 @@ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = wri
|
|
|
272
273
|
const context = `${w.name}, new in the last ${hours} hours (newest first):\n${listed}` + (b ? `\n\nThe brief:\n${b.text}` : "") + (peeks.length ? `\n\nWhat's behind the links (looked up just now):\n${peeks.map(peekLine).join("\n")}` : "");
|
|
273
274
|
const history = (w.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "You"}: ${m.text}`).join("\n\n");
|
|
274
275
|
const r = await converse({ where: `${w.name} card`, role, context, history, question, ask, now,
|
|
275
|
-
act: {
|
|
276
|
+
act: { // a repo named: its lane (its tasks, its agent); none: an agent of its own
|
|
277
|
+
agent: (req, known, repo) => (repo ? actIn(req, repo, { map: lanes(), known, title: w.name, context: `${w.name}, new lately:\n${listed}`, ops: run, now }) : run(req, { title: w.name, context: `${w.name}, new lately:\n${listed}`, known, now })),
|
|
278
|
+
task: (text, repo) => taskIn(text, repo, { map: lanes() }) } });
|
|
276
279
|
const answer = r.reply;
|
|
277
280
|
// read again: a check may have saved the file while the model answered
|
|
278
281
|
const d2 = loadWatch(), w2 = d2.watches.find((x) => x.id === id); if (!w2) return { answer, chat: [] };
|
package/writeups.mjs
CHANGED
|
@@ -8,9 +8,8 @@ import { resolveProvider, write } from "./ai.mjs";
|
|
|
8
8
|
import { detectMailSources, mailActivity } from "./mail.mjs";
|
|
9
9
|
import { waitingOn, newsSince } from "./watch.mjs";
|
|
10
10
|
import { me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, commits, openWork, detectFolder, repoPathMap, discoveredRepos } from "./scan.mjs";
|
|
11
|
-
import { taskType, workingChanges, workingDiff
|
|
12
|
-
import {
|
|
13
|
-
import { converse, actNow, addToTasks } from "./mind.mjs";
|
|
11
|
+
import { taskType, workingChanges, workingDiff } from "./tasks.mjs";
|
|
12
|
+
import { converse, actNow, actIn, taskIn } from "./mind.mjs";
|
|
14
13
|
|
|
15
14
|
// ---- render ---------------------------------------------------------------
|
|
16
15
|
function renderCommits(list) {
|
|
@@ -152,14 +151,12 @@ async function taskChat(id, question) {
|
|
|
152
151
|
(ctx.length ? "\n\n" + ctx.join("\n\n") : path ? "" : "\n\n(no repo attached: answer from the task text alone)");
|
|
153
152
|
// "do it" here works on the task's repo the way Send to repos does: the request
|
|
154
153
|
// joins the repo's tasks and the agent starts there; with no repo, in a run of its own
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const e = runHandoff(path); if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
|
|
160
|
-
return e.busy ? { ok: true, note: "Your agent is already working in that repo: it takes this up when it's done." } : { ok: true, job: e.id };
|
|
154
|
+
const lanes = repoPathMap(), mine = path ? it.repo : "";
|
|
155
|
+
const agent = async (req, known, repo) => {
|
|
156
|
+
const lane = repo || mine;
|
|
157
|
+
return lane ? actIn(req, lane, { map: lanes, known, title: it.text.slice(0, 60), context: `Their task: ${it.text}` }) : actNow(req, { title: it.text.slice(0, 60), context: `Their task: ${it.text}`, known });
|
|
161
158
|
};
|
|
162
|
-
const r = await converse({ where: `Task: ${it.text.slice(0, 60)}`, role, context, history, question, act: { agent, task: (text, repo) =>
|
|
159
|
+
const r = await converse({ where: `Task: ${it.text.slice(0, 60)}`, role, context, history, question, map: lanes, act: { agent, task: (text, repo) => taskIn(text, repo || mine, { map: lanes }) } });
|
|
163
160
|
const answer = r.reply;
|
|
164
161
|
// Re-read: other requests may have changed tasks.json while the model ran.
|
|
165
162
|
const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
|