symbiot 0.63.8 → 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 +5 -0
- package/agents.mjs +23 -8
- package/package.json +1 -1
- package/ui.mjs +1 -1
- package/work.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
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
|
+
|
|
5
10
|
## 0.63.8
|
|
6
11
|
|
|
7
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.
|
package/agents.mjs
CHANGED
|
@@ -236,13 +236,28 @@ function sandboxEnv(box, { token = () => (hasCmd("gh") ? sh("gh auth token").tri
|
|
|
236
236
|
// For Settings → Handoff: { on, ready, missing } (on: repo runs get it).
|
|
237
237
|
function sandboxState() { const n = sandboxNeeds(); return { on: loadConfig().agentSandbox !== false && trustFull(), ready: n.ready, missing: n.missing, ...(n.unsupported ? { unsupported: true } : {}) }; }
|
|
238
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
|
+
}
|
|
239
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.";
|
|
240
253
|
function sessionOf(path) { try { return JSON.parse(readSymbiot(path, SESSION)) || null; } catch { return null; } }
|
|
241
254
|
// Resume when an answer has come in since its last run, and the conversation is fresh enough
|
|
242
255
|
function resumeFor(path) {
|
|
243
256
|
const s = sessionOf(path); if (!s || !s.id || (s.runs || 0) >= RESUME_MAX || Date.now() - (s.at || 0) > RESUME_AGE) return null;
|
|
244
257
|
let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
|
|
245
|
-
|
|
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;
|
|
246
261
|
}
|
|
247
262
|
function noteSession(path, code, resumed) {
|
|
248
263
|
const f = join(path, ".symbiot", SESSION);
|
|
@@ -251,7 +266,7 @@ function noteSession(path, code, resumed) {
|
|
|
251
266
|
if (resumed && /No conversation found|session .*not found/i.test(readSymbiot(path, "agent.log").slice(-2000))) { try { unlinkSync(f); } catch {} return "lost"; }
|
|
252
267
|
return null;
|
|
253
268
|
}
|
|
254
|
-
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 {} }
|
|
255
270
|
return run.session || null;
|
|
256
271
|
}
|
|
257
272
|
// One agent per folder: two identical runs once started on the same repo 6s
|
|
@@ -278,8 +293,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
|
278
293
|
const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
|
|
279
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
|
|
280
295
|
tmpl = withStream(withTrust(scoped, box));
|
|
281
|
-
const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
|
|
282
|
-
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;
|
|
283
298
|
if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
|
|
284
299
|
const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
|
|
285
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
|
|
@@ -289,8 +304,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
|
289
304
|
const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
|
|
290
305
|
// SYMBIOT_AGENT: which run a `symbiot screens` command comes from, so it gets its own tab in the app's hidden browser
|
|
291
306
|
const env = { ...(own || {}), ...(boxEnv || {}), ...(owner || {}), SYMBIOT_AGENT: repoPath };
|
|
292
|
-
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) => {
|
|
293
|
-
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
|
|
294
309
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
295
310
|
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
296
311
|
setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
|
|
@@ -1374,7 +1389,7 @@ function workOf(path, log) {
|
|
|
1374
1389
|
const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
|
|
1375
1390
|
const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
|
|
1376
1391
|
const plan = w.stream ? briefPlan(md) : [], line = w.stream && !w.final ? runLine({ plan, step: stepOf(plan, w.talk), work: w }) : null;
|
|
1377
|
-
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;
|
|
1378
1393
|
// its kind (a brief, or answers) from its command line, and the task it's on: what estimate.mjs compares it with
|
|
1379
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); }
|
|
1380
1395
|
return { tail, work, progress: total ? { done, total } : null, ...(line ? { line } : {}) };
|
|
@@ -1391,4 +1406,4 @@ function agentsList() {
|
|
|
1391
1406
|
}).concat(earlierRuns());
|
|
1392
1407
|
}
|
|
1393
1408
|
|
|
1394
|
-
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/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": {
|
package/ui.mjs
CHANGED
|
@@ -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>";
|
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; }
|