symbiot 0.63.7 → 0.63.9
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 +10 -0
- package/agents.mjs +32 -9
- package/handover.mjs +5 -1
- package/home.mjs +2 -41
- package/package.json +2 -1
- package/phone.mjs +1 -1
- package/plain.mjs +48 -0
- package/ui.mjs +7 -7
- package/work.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.63.9
|
|
6
|
+
|
|
7
|
+
- **Answering an agent no longer rereads its whole conversation.** Each answer you gave an agent picked its conversation up again, all of it: a one-word "yes" to an agent that had read 195,000 tokens cost 195,000 again, and resumed runs were three quarters of a week's use. Once an agent's conversation is past 60k, your answer now starts it fresh (about 22k), with a note of where it got to in its own words. Short ones still carry on as before.
|
|
8
|
+
- **See how much an agent carries.** A run on the Workdesk shows its context: what it rereads with every step.
|
|
9
|
+
|
|
10
|
+
## 0.63.8
|
|
11
|
+
|
|
12
|
+
- **No more file names on permission questions, anywhere.** When an agent asked for a permission, Home said it in plain words with an Allow button, but the same question on the Workdesk, on a project's card and on your phone still read "allow the list in .symbiot/allowlist.proposed.json". Every screen now says what the agent wants to do ("connect to the computer at 13.51.104.186") with Allow.
|
|
13
|
+
- **Allow sticks.** Saying Allow along with a few words of your own that sounded like git ("check the deployed commit, git status and served files") was taken for a git chore and handed back to the agent, which can't give itself permission, so it asked you again. A permission is now always turned on as one.
|
|
14
|
+
|
|
5
15
|
## 0.63.7 — 2026-10-12
|
|
6
16
|
|
|
7
17
|
- **`symbiot capture Symbiot` works in one go.** It used to refuse, because your running Symbiot shows your real work, and needed a copy started by hand. It now starts a throwaway Symbiot with a made-up company in it (Acme: its repos, its agents' questions, what arrived, its week, a Marketing draft), captures Home, the Workdesk, the Dashboard, the Week, the Map and Marketing, then stops it and deletes it. Symbiot's own tray can be brought up to date after every release.
|
package/agents.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { randomBytes, createHash } from "node:crypto";
|
|
|
12
12
|
import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
13
13
|
import { parseRun, lastRunText, readRunLog, briefPlan, stepOf, runLine } from "./work.mjs";
|
|
14
14
|
import { noteDuration, kindOf } from "./estimate.mjs";
|
|
15
|
+
import { plainAsk } from "./plain.mjs";
|
|
15
16
|
import { parseFacts, leftToYou, gitWork, agentsGitWork, agentsPosting, linkedAsks, linkedChore, PUSH_OK } from "./handover.mjs";
|
|
16
17
|
import { scanRoots, repoPathMap } from "./scan.mjs";
|
|
17
18
|
import { knowledgeFolders } from "./knowledge.mjs";
|
|
@@ -235,13 +236,28 @@ function sandboxEnv(box, { token = () => (hasCmd("gh") ? sh("gh auth token").tri
|
|
|
235
236
|
// For Settings → Handoff: { on, ready, missing } (on: repo runs get it).
|
|
236
237
|
function sandboxState() { const n = sandboxNeeds(); return { on: loadConfig().agentSandbox !== false && trustFull(), ready: n.ready, missing: n.missing, ...(n.unsupported ? { unsupported: true } : {}) }; }
|
|
237
238
|
const SESSION = "session.json", RESUME_MAX = 8, RESUME_AGE = 2 * 86400000;
|
|
239
|
+
// A resumed run rereads its whole conversation with every step: a one-word answer
|
|
240
|
+
// to an agent that had read 195k tokens cost 195k again, and 55 resumed runs were
|
|
241
|
+
// three quarters of a week's use (2026-10-12). Past RESUME_CTX, an answer starts
|
|
242
|
+
// it fresh instead (about 22k to start), with its own last words in CARRYON.md.
|
|
243
|
+
const RESUME_CTX = 60000, CARRYON = "CARRYON.md";
|
|
244
|
+
const tooLong = (s) => !!s && (s.ctx || 0) > RESUME_CTX;
|
|
245
|
+
const CARRY_PROMPT = HANDOFF_PROMPT + " The user has just answered your questions: the newest entries in .symbiot/ANSWERS.md. This is a fresh start because the conversation of the last run had grown too long to carry on, so read .symbiot/CARRYON.md first: where that run got to, in its own words. Do not redo what it says is done."; // no quotes, $ or backticks: like HANDOFF_PROMPT it may pass through two shells
|
|
246
|
+
// The last run's own account of where it got to, for the fresh run that carries on.
|
|
247
|
+
function carryOn(path) {
|
|
248
|
+
let said = ""; try { said = parseRun(lastRunText(readSymbiot(path, "agent.log"))).final || ""; } catch {}
|
|
249
|
+
try { writeFileSync(join(path, ".symbiot", CARRYON), `# Where the last run got to\n\nIts conversation had grown too long to resume, so this run starts fresh. What it said at the end:\n\n${said.slice(0, 6000) || "(it didn't say)"}\n`); } catch {}
|
|
250
|
+
return CARRY_PROMPT;
|
|
251
|
+
}
|
|
238
252
|
const RESUME_PROMPT = "The user has answered: read the newest entries in .symbiot/ANSWERS.md and carry on with .symbiot/TASKS.md from where you were: finish the item you were on, then the next. Same rules as before.";
|
|
239
253
|
function sessionOf(path) { try { return JSON.parse(readSymbiot(path, SESSION)) || null; } catch { return null; } }
|
|
240
254
|
// Resume when an answer has come in since its last run, and the conversation is fresh enough
|
|
241
255
|
function resumeFor(path) {
|
|
242
256
|
const s = sessionOf(path); if (!s || !s.id || (s.runs || 0) >= RESUME_MAX || Date.now() - (s.at || 0) > RESUME_AGE) return null;
|
|
243
257
|
let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
|
|
244
|
-
|
|
258
|
+
if (!(answered > (s.at || 0))) return null;
|
|
259
|
+
if (s.ctx == null) { try { s.ctx = parseRun(lastRunText(readSymbiot(path, "agent.log"))).ctx || 0; } catch {} } // a session noted before 0.63.9: its size from the log
|
|
260
|
+
return s;
|
|
245
261
|
}
|
|
246
262
|
function noteSession(path, code, resumed) {
|
|
247
263
|
const f = join(path, ".symbiot", SESSION);
|
|
@@ -250,7 +266,7 @@ function noteSession(path, code, resumed) {
|
|
|
250
266
|
if (resumed && /No conversation found|session .*not found/i.test(readSymbiot(path, "agent.log").slice(-2000))) { try { unlinkSync(f); } catch {} return "lost"; }
|
|
251
267
|
return null;
|
|
252
268
|
}
|
|
253
|
-
if (run.session) { const prev = sessionOf(path) || {}; try { writeFileSync(f, JSON.stringify({ id: run.session, at: Date.now(), runs: prev.id === run.session ? (prev.runs || 1) + 1 : 1 })); } catch {} }
|
|
269
|
+
if (run.session) { const prev = sessionOf(path) || {}; try { writeFileSync(f, JSON.stringify({ id: run.session, at: Date.now(), runs: prev.id === run.session ? (prev.runs || 1) + 1 : 1, ...(run.ctx ? { ctx: run.ctx } : {}) })); } catch {} }
|
|
254
270
|
return run.session || null;
|
|
255
271
|
}
|
|
256
272
|
// One agent per folder: two identical runs once started on the same repo 6s
|
|
@@ -277,8 +293,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
|
277
293
|
const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
|
|
278
294
|
const scoped = withScope(withConnectors(tmpl, repoPath), repoPath), box = isClaudeCmd(scoped) && trustFull() ? sandboxFor(repoPath, scoped) : null; // a repo run's sandbox, on top of the membrane
|
|
279
295
|
tmpl = withStream(withTrust(scoped, box));
|
|
280
|
-
const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
|
|
281
|
-
const runCmd = resume ? tmpl + ` --resume ${resume.id}` : tmpl;
|
|
296
|
+
const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null, fresh = tooLong(resume); // too long to resume: a fresh run carries on
|
|
297
|
+
const runCmd = resume && !fresh ? tmpl + ` --resume ${resume.id}` : tmpl;
|
|
282
298
|
if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
|
|
283
299
|
const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
|
|
284
300
|
try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
|
|
@@ -288,8 +304,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
|
288
304
|
const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
|
|
289
305
|
// SYMBIOT_AGENT: which run a `symbiot screens` command comes from, so it gets its own tab in the app's hidden browser
|
|
290
306
|
const env = { ...(own || {}), ...(boxEnv || {}), ...(owner || {}), SYMBIOT_AGENT: repoPath };
|
|
291
|
-
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
|
|
292
|
-
if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
307
|
+
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, fresh ? carryOn(repoPath) : resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
|
|
308
|
+
if (noteSession(repoPath, code, !!resume && !fresh) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
293
309
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
294
310
|
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
295
311
|
setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
|
|
@@ -1079,6 +1095,13 @@ function skipIdea(path, text) {
|
|
|
1079
1095
|
}
|
|
1080
1096
|
return { ok: true };
|
|
1081
1097
|
}
|
|
1098
|
+
// A question as every screen shows it (plain.mjs): `shown`, its words with no file in them,
|
|
1099
|
+
// and `labels`, its choices the same way ("Allow" for an allow list it proposed). q and
|
|
1100
|
+
// options stay as the agent wrote them: an answer is saved under the agent's own words.
|
|
1101
|
+
function plainly(path, x) {
|
|
1102
|
+
let p = {}; try { p = plainAsk(path, x, { max: x.options.length }); } catch {}
|
|
1103
|
+
return { ...x, ...(p.raw ? { shown: p.q } : {}), ...(p.labels ? { labels: p.labels } : {}) };
|
|
1104
|
+
}
|
|
1082
1105
|
function agentQuestions(path, repo) {
|
|
1083
1106
|
const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
|
|
1084
1107
|
const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
|
|
@@ -1087,7 +1110,7 @@ function agentQuestions(path, repo) {
|
|
|
1087
1110
|
const ideas = p.suggestions.map(suggestionTarget).filter(({ text }) => !skipped.some((s) => sameTask(s, text)))
|
|
1088
1111
|
.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);
|
|
1089
1112
|
return {
|
|
1090
|
-
questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
|
|
1113
|
+
questions: open.slice(0, QUESTIONS_MAX).map((x) => plainly(path, { ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
|
|
1091
1114
|
suggestions: ideas, ideasShown: IDEAS_SHOWN,
|
|
1092
1115
|
};
|
|
1093
1116
|
}
|
|
@@ -1366,7 +1389,7 @@ function workOf(path, log) {
|
|
|
1366
1389
|
const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
|
|
1367
1390
|
const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
|
|
1368
1391
|
const plan = w.stream ? briefPlan(md) : [], line = w.stream && !w.final ? runLine({ plan, step: stepOf(plan, w.talk), work: w }) : null;
|
|
1369
|
-
const work = w.stream ? { model: w.model, doing: w.doing, moved: w.moved, looping: w.looping, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
|
|
1392
|
+
const work = w.stream ? { model: w.model, doing: w.doing, moved: w.moved, looping: w.looping, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, ctx: w.ctx, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
|
|
1370
1393
|
// its kind (a brief, or answers) from its command line, and the task it's on: what estimate.mjs compares it with
|
|
1371
1394
|
if (line) { const c = String(log || "").slice(String(log || "").lastIndexOf("\n=== ")).split("\n")[2] || ""; line.kind = kindOf(c); line.task = firstOpen(md).slice(0, 200); }
|
|
1372
1395
|
return { tail, work, progress: total ? { done, total } : null, ...(line ? { line } : {}) };
|
|
@@ -1383,4 +1406,4 @@ function agentsList() {
|
|
|
1383
1406
|
}).concat(earlierRuns());
|
|
1384
1407
|
}
|
|
1385
1408
|
|
|
1386
|
-
export { CHANGES, toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, unchangedStep, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
|
|
1409
|
+
export { CHANGES, toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, unchangedStep, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, tooLong, carryOn, RESUME_CTX, CARRY_PROMPT, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
|
package/handover.mjs
CHANGED
|
@@ -66,7 +66,11 @@ function parseAwaiting(text) {
|
|
|
66
66
|
// is still theirs.
|
|
67
67
|
const GIT_WORK = /\bgit\s+(?:add|commit|push|pull|fetch|merge|rebase|stash|restore|checkout|switch)\b|\b(?:stage|commit|push|pull)(?:es|ed|ing)?\b(?:[^.;:]{0,60})\b(?:changes?|diff|fix|files?|branch|main|master|origin|upstream|remote|commits?|them|it)\b/i;
|
|
68
68
|
const IDENTITY = /\bpassword\b|\b2fa\b|\btwo-factor\b|\b(?:api )?token\b|\bsign(?:ing)? in\b|\blog ?in\b|\bssh key\b|\bpassphrase\b|\bsudo\b/i;
|
|
69
|
-
|
|
69
|
+
// A permission is never git work, whatever its words: "allow the list … to check the
|
|
70
|
+
// deployed commit, git status and served files" was taken for a commit, and handed back to
|
|
71
|
+
// an agent that can't give itself permission, which asked again (WinLove, 2026-10-12).
|
|
72
|
+
const PERMISSION = /allowlist\.proposed|only you: a permission/i;
|
|
73
|
+
const gitWork = (text) => { const t = String(text || ""); return GIT_WORK.test(t) && !IDENTITY.test(t) && !PERMISSION.test(t); };
|
|
70
74
|
const PUSH_OK = "asking for your OK on the Workdesk, with the files, before it pushes";
|
|
71
75
|
// A "👤 You: …" option that's local git work, as the agent's: "🤖 Agent: … (asking for
|
|
72
76
|
// your OK … before it pushes)". Dropped instead when another option already has the
|
package/home.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// how you use them; this is the live data around them. Home's talk goes to the
|
|
11
11
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
12
12
|
import { claudeSetup } from "./claudesetup.mjs";
|
|
13
|
+
import { plainAsk } from "./plain.mjs";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { homedir } from "node:os";
|
|
15
16
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
@@ -409,48 +410,8 @@ function noteAnswer(path, heading, text) {
|
|
|
409
410
|
const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
|
|
410
411
|
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
|
|
411
412
|
}
|
|
412
|
-
// An ask in words for someone who doesn't code: a permission request lists what the agent
|
|
413
|
-
// wants to run, with Allow / Don't allow; any file path left in the question or choices
|
|
414
|
-
// becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
|
|
415
|
-
const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
|
|
416
|
-
// What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
|
|
417
|
-
const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
|
|
418
|
-
function PLAIN_RULE(r) {
|
|
419
|
-
const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
|
|
420
|
-
const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
|
|
421
|
-
if (tool === "Read") return `read files in ${base(a)}`;
|
|
422
|
-
if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
|
|
423
|
-
if (tool !== "Bash") return `use ${tool}`;
|
|
424
|
-
const host = (a.match(/@([\w.-]+)/) || [])[1];
|
|
425
|
-
if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
|
|
426
|
-
if (first === "adb") return "use your phone over the USB cable";
|
|
427
|
-
if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
|
|
428
|
-
if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
|
|
429
|
-
if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
|
|
430
|
-
if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
|
|
431
|
-
if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
|
|
432
|
-
if (/^(python\d*|node)$/.test(first)) return "run code in this project";
|
|
433
|
-
if (/^(rm|shred)$/.test(first)) return "delete files";
|
|
434
|
-
if (first === "sudo") return "make changes as administrator";
|
|
435
|
-
return `run ${first}`;
|
|
436
|
-
}
|
|
437
413
|
// a label without its "(recommended)": a second button isn't the recommended one
|
|
438
414
|
const blobLabel = (l) => String(l || "").replace(/\s*\(recommended\)\s*$/i, "");
|
|
439
|
-
function plainAsk(path, q) {
|
|
440
|
-
const text = String(q.q || ""), opts = (q.options || []).slice(0, 2);
|
|
441
|
-
let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
|
|
442
|
-
if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
|
|
443
|
-
let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
|
|
444
|
-
ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
|
|
445
|
-
labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
|
|
446
|
-
}
|
|
447
|
-
// raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
|
|
448
|
-
// answerQuestions). Sending the plain words back matched nothing, so a permission card's
|
|
449
|
-
// Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
|
|
450
|
-
const shown = ask.replace(PATHISH, "a file");
|
|
451
|
-
return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
|
|
452
|
-
}
|
|
453
|
-
|
|
454
415
|
function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
455
416
|
id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
|
|
456
417
|
const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
|
|
@@ -688,7 +649,7 @@ function workScene({ deps = {}, full = false } = {}) {
|
|
|
688
649
|
const line = plain(String(t).split(/\n\s*\n/)[0], 220);
|
|
689
650
|
if (line) { p.summary = line; p.state = a.status === "running" ? "at work" : qs.length ? "asks you" : "done"; p.path = a.path; }
|
|
690
651
|
}
|
|
691
|
-
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
|
|
652
|
+
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.shown || q.q, ...(q.shown ? { raw: q.q } : {}), options: (q.options || []).slice(0, 3), ...(q.labels ? { labels: q.labels } : {}), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
|
|
692
653
|
for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
|
|
693
654
|
}
|
|
694
655
|
// what each lane handed over and hasn't heard back on, with where it stands and how far the run on it has got
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.63.
|
|
3
|
+
"version": "0.63.9",
|
|
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": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"away.mjs",
|
|
47
47
|
"marketing.mjs",
|
|
48
48
|
"tray.mjs",
|
|
49
|
+
"plain.mjs",
|
|
49
50
|
"capture.mjs",
|
|
50
51
|
"demo.mjs",
|
|
51
52
|
"postinstall.mjs",
|
package/phone.mjs
CHANGED
|
@@ -283,7 +283,7 @@ async function snapshot({ now = Date.now(), fresh = false } = {}) {
|
|
|
283
283
|
const asks = [];
|
|
284
284
|
for (const y of (await tryOr(() => agents.agentsList(), [])).filter((a, i, all) => all.findIndex((b) => b.path === a.path) === i)) {
|
|
285
285
|
const qs = await tryOr(() => agents.agentQuestions(y.path, "").questions, []);
|
|
286
|
-
if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)) })) });
|
|
286
|
+
if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), ...(q.shown ? { shown: plainText(q.shown, 400) } : {}), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)), ...(q.labels ? { labels: q.labels.slice(0, 2).map((o) => plainText(o, 200)) } : {}) })) });
|
|
287
287
|
}
|
|
288
288
|
const approves = (await tryOr(() => tasksMod.pendingReview(), [])).filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length))
|
|
289
289
|
.map((r) => ({ repo: r.repo, tasks: (r.tasks || []).map((t) => ({ id: t.id, text: plainText(t.text, 200) })), files: (r.files || []).length, stat: plainText(r.stat, 120), ...(r.untasked ? { untasked: true } : {}), sum: approveSum(r) }));
|
package/plain.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A question an agent left, in words for someone who doesn't code (plainAsk): Home,
|
|
2
|
+
// the Workdesk's agent blocks, a project's card and the phone all show it this way, so
|
|
3
|
+
// "allow the list in .symbiot/allowlist.proposed.json" never reaches a screen. It was
|
|
4
|
+
// only Home's list: the same ask on the Workdesk still read as a file (2026-10-12).
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
// An ask in words for someone who doesn't code: a permission request lists what the agent
|
|
9
|
+
// wants to run, with Allow / Don't allow; any file path left in the question or choices
|
|
10
|
+
// becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
|
|
11
|
+
const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
|
|
12
|
+
// What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
|
|
13
|
+
const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
|
|
14
|
+
function PLAIN_RULE(r) {
|
|
15
|
+
const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
|
|
16
|
+
const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
|
|
17
|
+
if (tool === "Read") return `read files in ${base(a)}`;
|
|
18
|
+
if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
|
|
19
|
+
if (tool !== "Bash") return `use ${tool}`;
|
|
20
|
+
const host = (a.match(/@([\w.-]+)/) || [])[1];
|
|
21
|
+
if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
|
|
22
|
+
if (first === "adb") return "use your phone over the USB cable";
|
|
23
|
+
if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
|
|
24
|
+
if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
|
|
25
|
+
if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
|
|
26
|
+
if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
|
|
27
|
+
if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
|
|
28
|
+
if (/^(python\d*|node)$/.test(first)) return "run code in this project";
|
|
29
|
+
if (/^(rm|shred)$/.test(first)) return "delete files";
|
|
30
|
+
if (first === "sudo") return "make changes as administrator";
|
|
31
|
+
return `run ${first}`;
|
|
32
|
+
}
|
|
33
|
+
function plainAsk(path, q, { max = 2 } = {}) {
|
|
34
|
+
const text = String(q.q || ""), opts = (q.options || []).slice(0, max);
|
|
35
|
+
let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
|
|
36
|
+
if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
|
|
37
|
+
let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
|
|
38
|
+
ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
|
|
39
|
+
labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
|
|
40
|
+
}
|
|
41
|
+
// raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
|
|
42
|
+
// answerQuestions). Sending the plain words back matched nothing, so a permission card's
|
|
43
|
+
// Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
|
|
44
|
+
const shown = ask.replace(PATHISH, "a file");
|
|
45
|
+
return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { PATHISH, PLAIN_RULE, plainAsk };
|
package/ui.mjs
CHANGED
|
@@ -1890,8 +1890,8 @@ function relHtml(rl){return "<div class='qrel"+(rl.waiting?" wait":"")+"'>Needs
|
|
|
1890
1890
|
function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.suggestions||[];if(!qs.length&&!ss.length)return '';
|
|
1891
1891
|
var h="<div class='aq' data-id='"+esc(a.id)+"'>";
|
|
1892
1892
|
if(qs.length){h+="<h4><i class=ic-ask></i> "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
|
|
1893
|
-
qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
|
|
1894
|
-
(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>";});
|
|
1893
|
+
qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.shown||q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
|
|
1894
|
+
(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((q.labels&&q.labels[j])||o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
|
|
1895
1895
|
h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
|
|
1896
1896
|
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>";}
|
|
1897
1897
|
if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4><i class=ic-idea></i> 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>";});
|
|
@@ -1971,7 +1971,7 @@ function wkSpark(p){if(!p||p.length<2)return '';var m=Math.max.apply(null,p)||1,
|
|
|
1971
1971
|
function workHtml(a){var w=a.work,pg=a.progress,run=a.status==='running',h='';
|
|
1972
1972
|
if(!w){if(run)h+="<div class='bar'><i></i></div>";if(pg)h+="<div class='wkstats'><span class='wkstat'><b>"+pg.done+"/"+pg.total+"</b> tasks ticked</span></div>";return h;}
|
|
1973
1973
|
var stats=[];if(w.model)stats.push("<span class='wkstat'>"+esc(w.model.split('[')[0])+"</span>");stats.push("<span class='wkstat'><b>"+w.count+"</b> step"+(w.count===1?'':'s')+"</span>");
|
|
1974
|
-
if(w.turns!=null)stats.push("<span class='wkstat'><b>"+w.turns+"</b> turns</span>");if(w.tokens)stats.push("<span class='wkstat'><b>"+(w.tokens>999999?(w.tokens/1e6).toFixed(1)+'M':w.tokens>999?Math.round(w.tokens/1000)+'k':w.tokens)+"</b> tokens</span>");
|
|
1974
|
+
if(w.turns!=null)stats.push("<span class='wkstat'><b>"+w.turns+"</b> turns</span>");if(w.tokens)stats.push("<span class='wkstat'><b>"+(w.tokens>999999?(w.tokens/1e6).toFixed(1)+'M':w.tokens>999?Math.round(w.tokens/1000)+'k':w.tokens)+"</b> tokens</span>");if(w.ctx)stats.push("<span class='wkstat' title='What it rereads with every step: the conversation so far. Past 60k, your next answer starts it fresh with a summary instead.'><b>"+(w.ctx>999?Math.round(w.ctx/1000)+'k':w.ctx)+"</b> context</span>");
|
|
1975
1975
|
if(w.cost!=null)stats.push("<span class='wkstat'><b>$"+w.cost.toFixed(2)+"</b></span>");if(w.tests)stats.push("<span class='wktest"+(w.tests.failed?' bad':'')+"'>"+w.tests.passed+" passed · "+w.tests.failed+" failed</span>");if(w.errors)stats.push("<span class='wktest bad'>"+w.errors+" error"+(w.errors>1?'s':'')+"</span>");
|
|
1976
1976
|
if(run&&w.doing)h+="<div class='wkdoing'>"+esc(w.doing)+"…</div>";
|
|
1977
1977
|
var first=(w.final||'').split(String.fromCharCode(10))[0];if(!run&&first)h+="<div class='wkfinal'>"+esc(first.length>180?first.slice(0,179)+'\u2026':first)+"</div>";
|
|
@@ -2175,8 +2175,8 @@ var h='',W="<span class='pcwait'> waiting to send</span>";
|
|
|
2175
2175
|
(c.notes||[]).forEach(function(n){h+="<div class='task' data-note='"+escQ(n.id)+"'><span class='t err'>"+esc(n.error)+(n.what?" <span class='muted'>("+esc(n.what)+")</span>":"")+"</span><button class='ghost pcnote'>OK</button></div>";});
|
|
2176
2176
|
var asks=c.asks||[],aps=c.approves||[],needs=c.needs||[];
|
|
2177
2177
|
if(asks.length||aps.length||needs.length){h+="<div class='mkneeds lit'><div class='pshead'><span>Needs you · "+(asks.length+aps.length+needs.length)+"</span><small>on "+esc(d.name)+"</small></div>";
|
|
2178
|
-
asks.forEach(function(a,i){var q=(a.questions||[])[0]||{};h+="<div class='pcq' data-ask='"+i+"'><div class='qn'>"+esc(a.name)+" asks"+((a.questions||[]).length>1?" · 1 of "+a.questions.length:"")+"</div><div class='qq'>"+esc(q.q||'')+"</div>"+
|
|
2179
|
-
(a.waiting?"<div class='note' style='margin:0'>You answered: "+esc((a.answered||[])[0]||'')+W+"</div>":"<div class='opts'>"+(q.options||[]).map(function(o,j){return "<button class='"+(j?'ghost':'act')+" pcopt' data-j='"+j+"'>"+esc(o)+"</button>";}).join('')+"</div><form class='pcown'><input placeholder='Or say it in your own words' maxlength='2000'><button class='ghost' type='submit'>Send</button></form>")+"</div>";});
|
|
2178
|
+
asks.forEach(function(a,i){var q=(a.questions||[])[0]||{};h+="<div class='pcq' data-ask='"+i+"'><div class='qn'>"+esc(a.name)+" asks"+((a.questions||[]).length>1?" · 1 of "+a.questions.length:"")+"</div><div class='qq'>"+esc(q.shown||q.q||'')+"</div>"+
|
|
2179
|
+
(a.waiting?"<div class='note' style='margin:0'>You answered: "+esc((a.answered||[])[0]||'')+W+"</div>":"<div class='opts'>"+(q.options||[]).map(function(o,j){return "<button class='"+(j?'ghost':'act')+" pcopt' data-j='"+j+"'>"+esc((q.labels&&q.labels[j])||o)+"</button>";}).join('')+"</div><form class='pcown'><input placeholder='Or say it in your own words' maxlength='2000'><button class='ghost' type='submit'>Send</button></form>")+"</div>";});
|
|
2180
2180
|
aps.forEach(function(a,i){h+="<div class='pcq' data-ap='"+i+"'><div class='qn'>"+esc(a.repo)+" · waiting for your Approve</div><div class='qq'>"+(a.tasks.length?a.tasks.length+" task"+(a.tasks.length>1?"s":"")+" done":"Changes without a task")+" · <span class='muted'>"+esc(a.stat||(a.files+" file"+(a.files===1?"":"s")+" changed"))+"</span></div>"+(a.tasks.length?"<ul>"+a.tasks.map(function(t){return "<li>"+esc(t.text)+"</li>";}).join('')+"</ul>":"")+
|
|
2181
2181
|
(a.waiting?"<div class='note' style='margin:0'>Approved here"+W+"</div>":"<div class='row' style='margin:0'><button class='act pcapprove'>Approve</button><span class='muted' style='font-size:12px'>exactly this: if the work changes before your computer gets it, it isn’t approved</span></div>")+"</div>";});
|
|
2182
2182
|
needs.forEach(function(n){h+="<div class='task'><span class='t'>"+esc(n.title)+" <span class='muted' style='font-size:12px'>"+esc(n.sub)+" · on your computer</span></span></div>";});
|
|
@@ -3118,13 +3118,13 @@ if(!ps.length){el.innerHTML='';return;}
|
|
|
3118
3118
|
el.innerHTML=ps.map(function(p,i){var nm=p.name||p.repo,q=(p.qs||[])[0],st=p.parked?'parked':(p.qs&&p.qs.length)?'asks you':p.ready?'ready for your OK':p.state||'';
|
|
3119
3119
|
var h="<section class='rly"+((p.qs&&p.qs.length)||p.ready?' lit':'')+"' data-i='"+i+"'><div class='rlh'><b>"+esc(nm)+"</b>"+(st?"<span class='rls'>"+esc(st)+"</span>":"")+"</div>";
|
|
3120
3120
|
if(p.summary)h+="<p class='rlsum' title='"+escQ(p.summary)+"'>"+esc(p.summary)+"</p>";
|
|
3121
|
-
if(q)h+="<div class='lqblob rlask'>"+blobBody({kind:'ask',name:nm,repo:p.repo,q:q.q,options:q.options,path:q.path,fix:q.fix,id:q.id,sub:q.sub,draft:q.draft,signInTo:q.signInTo,more:Math.max(0,(p.asks||p.qs.length)-1)},false)+"</div>";
|
|
3121
|
+
if(q)h+="<div class='lqblob rlask'>"+blobBody({kind:'ask',name:nm,repo:p.repo,q:q.q,raw:q.raw,labels:q.labels,options:q.options,path:q.path,fix:q.fix,id:q.id,sub:q.sub,draft:q.draft,signInTo:q.signInTo,more:Math.max(0,(p.asks||p.qs.length)-1)},false)+"</div>";
|
|
3122
3122
|
if(p.ready)h+="<button type='button' class='act rlok'>Review and approve</button>";
|
|
3123
3123
|
if(p.handing&&p.handing.length)h+="<div class='rlk'>Handed over</div>"+p.handing.map(function(x){var pg=handProg(x);return "<p class='rlsum rlhand' title='"+escQ(x.text)+"'><b>"+esc(x.lane)+"</b>: "+esc(x.why)+(pg?" \u00b7 "+esc(pg):"")+"</p>";}).join('');
|
|
3124
3124
|
if(p.ideas&&p.ideas.length)h+="<div class='rlk'>Extra tasks it suggests</div>"+p.ideas.map(function(d,j){return "<div class='rli' data-j='"+j+"'><span title='"+escQ(d.full)+"'>"+esc(d.text)+"</span><span class='rlia'><button type='button' class='rladd'>+ task</button><button type='button' class='rlskip'>Skip</button></span></div>";}).join('');
|
|
3125
3125
|
return h+"<div class='rlf'><button type='button' class='rlopen'>Its tasks ›</button><button type='button' class='rlag'>Its agents ›</button></div></section>";}).join('');
|
|
3126
3126
|
el.querySelectorAll('.rly').forEach(function(box){var p=ps[+box.getAttribute('data-i')],q=(p.qs||[])[0],nm=p.name||p.repo;
|
|
3127
|
-
var ab=box.querySelector('.rlask');if(ab&&q)wireBlob(ab,{kind:'ask',name:nm,repo:p.repo,q:q.q,options:q.options,path:q.path,fix:q.fix,id:q.id},{done:function(){LQ.relaySig='';lqLoadWork();}});
|
|
3127
|
+
var ab=box.querySelector('.rlask');if(ab&&q)wireBlob(ab,{kind:'ask',name:nm,repo:p.repo,q:q.q,raw:q.raw,labels:q.labels,options:q.options,path:q.path,fix:q.fix,id:q.id},{done:function(){LQ.relaySig='';lqLoadWork();}});
|
|
3128
3128
|
var ok=box.querySelector('.rlok');if(ok)ok.addEventListener('click',function(){lqOpen({kind:'proj',repo:p.repo,lit:true});});
|
|
3129
3129
|
box.querySelector('.rlopen').addEventListener('click',function(){lqOpen({kind:'proj',repo:p.repo});});
|
|
3130
3130
|
box.querySelector('.rlag').addEventListener('click',function(){lqPool('agents');});
|
package/work.mjs
CHANGED
|
@@ -45,12 +45,12 @@ const resultText = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map(
|
|
|
45
45
|
|
|
46
46
|
// The text of one run's log (everything after its "=== name time ===" header and
|
|
47
47
|
// command line) → { stream, model, steps, todos, doing, said, final, cost, turns,
|
|
48
|
-
// tokens, tests, files, pace, errors }.
|
|
48
|
+
// tokens, ctx (what its last step read: the conversation so far), tests, files, pace, errors }.
|
|
49
49
|
function parseRun(text) {
|
|
50
50
|
const lines = String(text || "").split("\n");
|
|
51
51
|
const events = []; let plain = [];
|
|
52
52
|
for (const l of lines) { if (l.startsWith("{")) { try { events.push(JSON.parse(l)); continue; } catch {} } if (l.trim()) plain.push(l); }
|
|
53
|
-
const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, tests: null, files: {}, pace: [], errors: 0 };
|
|
53
|
+
const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, ctx: null, tests: null, files: {}, pace: [], errors: 0 };
|
|
54
54
|
if (!out.stream) { out.said = plain.slice(-12).map((l) => short(l, 160)); out.final = plain.slice(-40).join("\n").trim(); return out; }
|
|
55
55
|
const byId = new Map(), tasks = new Map(), talk = []; let thinking = false, moved = 0;
|
|
56
56
|
for (const e of events) {
|
|
@@ -60,6 +60,7 @@ function parseRun(text) {
|
|
|
60
60
|
if (e.session_id && !out.session) out.session = String(e.session_id);
|
|
61
61
|
if (e.type === "system" && e.subtype === "init") { out.model = String(e.model || ""); continue; }
|
|
62
62
|
if (e.type === "assistant" && e.message && Array.isArray(e.message.content)) {
|
|
63
|
+
const u = e.message.usage; if (u) out.ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0) || out.ctx; // what its next step rereads
|
|
63
64
|
for (const b of e.message.content) {
|
|
64
65
|
if (b.type === "thinking") { thinking = true; continue; }
|
|
65
66
|
if (b.type === "text" && b.text && b.text.trim()) { out.said.push(short(b.text, 220)); talk.push(short(b.text, 400)); thinking = false; continue; }
|