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 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) && qKey(t.text) === qKey(text)) })),
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.0",
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 text already open in the same repo (in review counts): that task, not a duplicate.
22
- const same = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
23
- const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && same(x.text) === same(text));
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].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
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 { const want = it.text.trim().toLowerCase(); writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase() === want ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
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