symbiot 0.41.0 → 0.41.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 +3 -3
- package/core.mjs +19 -1
- package/package.json +7 -2
- package/tasks.mjs +12 -9
package/agents.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { loadConfig, saveConfig, loadTasks, sh, hasCmd } from "./core.mjs";
|
|
9
|
+
import { loadConfig, saveConfig, loadTasks, sameTask, sh, hasCmd } from "./core.mjs";
|
|
10
10
|
|
|
11
11
|
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
12
12
|
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
@@ -315,7 +315,7 @@ function suggestionTarget(s) {
|
|
|
315
315
|
}
|
|
316
316
|
// The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each with
|
|
317
317
|
// the repo whose tasks it goes to (this one unless it names another) and marked
|
|
318
|
-
// if it's already on that repo's task list.
|
|
318
|
+
// if it's already on that repo's task list, in these words or nearly (sameTask).
|
|
319
319
|
function agentQuestions(path, repo) {
|
|
320
320
|
const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
|
|
321
321
|
const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
|
|
@@ -323,7 +323,7 @@ function agentQuestions(path, repo) {
|
|
|
323
323
|
const tasks = p.suggestions.length ? loadTasks() : [];
|
|
324
324
|
return {
|
|
325
325
|
questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
|
|
326
|
-
suggestions: p.suggestions.map(suggestionTarget).map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) &&
|
|
326
|
+
suggestions: p.suggestions.map(suggestionTarget).map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })),
|
|
327
327
|
};
|
|
328
328
|
}
|
|
329
329
|
// Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
|
package/core.mjs
CHANGED
|
@@ -36,6 +36,24 @@ function saveConfig(cfg) {
|
|
|
36
36
|
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
37
37
|
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
38
38
|
function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
|
|
39
|
+
// Two wordings of one task: the same words once case, spacing and punctuation
|
|
40
|
+
// are set aside (a colon for a bracket, `code` for code), or one is the other
|
|
41
|
+
// with a clause more on the end. An extension only counts when the shorter one
|
|
42
|
+
// is a whole sentence (8+ words), so "Fix the bug" never swallows "Fix the bug
|
|
43
|
+
// in the login form".
|
|
44
|
+
const taskWords = (s) => String(s || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
45
|
+
function sameTask(a, b) {
|
|
46
|
+
const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
|
|
47
|
+
if (x === y) return true;
|
|
48
|
+
const [s, l] = x.length < y.length ? [x, y] : [y, x];
|
|
49
|
+
return s.split(" ").length >= 8 && l.startsWith(s + " ");
|
|
50
|
+
}
|
|
51
|
+
// One of each: near-duplicates collapse into the wording that says the most.
|
|
52
|
+
function uniqueTasks(texts) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const t of texts) { const i = out.findIndex((u) => sameTask(u, t)); if (i < 0) out.push(t); else if (taskWords(t).length > taskWords(out[i]).length) out[i] = t; }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
39
57
|
|
|
40
58
|
// ---- shell + git ----------------------------------------------------------
|
|
41
59
|
function sh(cmd) {
|
|
@@ -84,4 +102,4 @@ function repoState(repoPath) {
|
|
|
84
102
|
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
85
103
|
}
|
|
86
104
|
|
|
87
|
-
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState };
|
|
105
|
+
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.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": {
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs"
|
|
60
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
61
|
+
"lint": "eslint ."
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"eslint": "^9.39.5",
|
|
65
|
+
"globals": "^17.13.0"
|
|
61
66
|
}
|
|
62
67
|
}
|
package/tasks.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
9
|
+
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
10
10
|
import { QUESTIONS_MAX, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
|
|
11
11
|
import { gitDefaultBranch } from "./drift.mjs";
|
|
12
12
|
import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
|
|
@@ -18,9 +18,12 @@ function addTask(text, repo) {
|
|
|
18
18
|
text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
|
|
19
19
|
if (!text) return { error: "empty" };
|
|
20
20
|
const t = loadTasks();
|
|
21
|
-
// The same
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// The same task already open in the same repo (in review counts), even in
|
|
22
|
+
// other words (sameTask): that task, not a duplicate. When the new wording has
|
|
23
|
+
// a clause more, the task takes it, unless it's in review (its tick is on the
|
|
24
|
+
// old words). Handed out already, a tick on either wording counts (syncTasks).
|
|
25
|
+
const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && sameTask(x.text, text));
|
|
26
|
+
if (dup && !dup.review && taskWords(text).length > taskWords(dup.text).length) { dup.text = text; saveTasks(t); return { ...dup, duplicate: true, reworded: true }; }
|
|
24
27
|
if (dup) return { ...dup, duplicate: true };
|
|
25
28
|
const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
|
|
26
29
|
t.unshift(item); saveTasks(t); return item;
|
|
@@ -46,7 +49,7 @@ function syncTasks() {
|
|
|
46
49
|
for (const x of t) {
|
|
47
50
|
if (x.archived || x.done || x.review || !x.repo) continue;
|
|
48
51
|
if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p && startHeldTasks(p)) started++; checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
|
|
49
|
-
if (checkedByRepo[x.repo].
|
|
52
|
+
if (checkedByRepo[x.repo].some((c) => sameTask(c, x.text))) { x.review = true; x.reviewAt = Date.now(); review++; }
|
|
50
53
|
}
|
|
51
54
|
for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
|
|
52
55
|
saveTasks(t);
|
|
@@ -345,7 +348,7 @@ function approveRepo(repo, opts = {}) {
|
|
|
345
348
|
if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
|
|
346
349
|
const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
|
|
347
350
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
348
|
-
const r = shipWithBump(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
351
|
+
const r = shipWithBump(path, uniqueTasks(items.map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
349
352
|
if (r.error) return r;
|
|
350
353
|
const now = Date.now();
|
|
351
354
|
for (const x of items) { x.review = false; 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]; }
|
|
@@ -368,7 +371,7 @@ function sendBack(id) {
|
|
|
368
371
|
const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
|
|
369
372
|
it.review = false; delete it.reviewAt; saveTasks(t);
|
|
370
373
|
const path = it.repo && repoPathMap()[it.repo];
|
|
371
|
-
if (path) { const f = join(path, ".symbiot", "TASKS.md"); try {
|
|
374
|
+
if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && sameTask(l.replace(/^\s*-\s*\[x\]\s*/i, ""), it.text) ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
|
|
372
375
|
return it;
|
|
373
376
|
}
|
|
374
377
|
// Deterministic task classification (no model) so tasks batch by kind instead
|
|
@@ -403,7 +406,7 @@ function buildTasksMd(name, ctx, list) {
|
|
|
403
406
|
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
|
|
404
407
|
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
405
408
|
`- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
|
|
406
|
-
"- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`.",
|
|
409
|
+
"- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`, `keep it as it is`) gets no tag.",
|
|
407
410
|
"- 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.",
|
|
408
411
|
"- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
|
|
409
412
|
"- 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.",
|
|
@@ -420,7 +423,7 @@ function pushTasks(filter) {
|
|
|
420
423
|
const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
|
|
421
424
|
const written = [], unresolved = [];
|
|
422
425
|
for (const name of Object.keys(groups)) {
|
|
423
|
-
const list = groups[name], path = name && byName[name];
|
|
426
|
+
const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
|
|
424
427
|
if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
|
|
425
428
|
try {
|
|
426
429
|
// Cheap, per-repo signals only — no full drift scan (that can be very slow
|