symbiot 0.49.1 → 0.50.0

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 CHANGED
@@ -2,6 +2,21 @@
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.50.0
6
+
7
+ - **The Map, by nearest neighbours.** Your repos are droplets in the liquid, each beside the repos most like it: what they're built with, the weeks you work on them, and what they're about (from their READMEs). The closest merge into one shape, the next are joined by a thread of liquid; each cluster is named over its region; what you haven't touched in a while is smaller and sinks; "you are here" sits where your recent work is. Point at a repo to see its neighbours and why; click for its details, now with **Most like it** and your last 12 weeks. Zoom and fit buttons, and the languages and tools you build with underneath instead of a coloured legend. Screens folds away.
8
+ - **Every panel in the look.** Week, Standup and Todo are a page with a title and dates, the write-up set as a document. Tasks are calm rows with round check beads; Ask and Remove are icons. Agents' questions get a calm card with bead radio buttons. Drift has a status bead per repo, switches for its options, and its issues listed under each. Settings is a column of titled cards.
9
+ - **Icons, not emoji.** Chat, person, agent, idea, question, archive and plug are drawn line icons in the look's ink.
10
+ - **Close stays clean.** Content scrolls under the Close bar instead of colliding with it.
11
+
12
+ ## 0.49.2
13
+
14
+ - **Projects shows your projects.** Click **Projects** on the liquid and each repo with work on it is a sphere: what's going on there under its name (an agent at work, ready for your OK, or how many tasks are waiting), spheres orbiting while an agent works, amber when its work waits for you. Click one to open just that project's tasks; right-click comes back.
15
+ - **The Dashboard, in detail.** A headline that says what's waiting, with when it was last read; a 24 hours / 3 days / 7 days switch instead of the old dropdown; a round refresh; **Close** instead of "Sink back". Feeds go by their short names ("Inbox", with your address under it). What you've already seen stays on the stream as small faint beads, so a quiet day isn't an empty line, and the stream lines up with "now" however wide the window is.
16
+ - **Connections.** The link chips are now a grid by kind of site, each with a bead: solid when linked, amber when it wants you to sign in, hollow when it isn't linked. Link, check and Unlink show when you point at one. It's on the Dashboard as well as in Settings.
17
+ - **Every panel's controls, in the look.** Dropdowns have a dark menu in Ferrofluid instead of the white one, buttons are solid in the look's ink (Pearl keeps its chrome), and the card list drops its emoji and green borders.
18
+ - **README** brought up to date: the looks, P.A.R.A., the stream, Connections, click to open and right-click to go back.
19
+
5
20
  ## 0.49.1
6
21
 
7
22
  - **Click a sphere, it opens.** A sphere (or its name) opens its screen straight away.
package/README.md CHANGED
@@ -41,43 +41,59 @@ visual app in your browser — in a clean, chrome-less window if you have
41
41
  Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. No Electron,
42
42
  no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
43
43
 
44
- Tabs:
45
-
46
- - **Map** (the landing view) — a live node graph of your work from your local git:
47
- you at the centre, your repos, the languages and tools they share (so related
48
- projects cluster), the coding **agents and editors** you have installed, and the
49
- **AI** powering Symbiot. Project folders that **aren't git repos** show up too
50
- (see [Where it looks](#where-it-looks)). **Scroll to zoom, drag to pan, click a
51
- node.** A repo's panel shows its branch, commits, uncommitted work and stack, a
52
- **Suggest next steps** button, and an **AI review**: what the project does, a
53
- one-line verdict on whether it needs new work at all (it prefers stabilising over
54
- new features), and ideas you can tick straight into **Tasks**. The graph needs
55
- **no AI key**; reviews and suggestions use your chosen model. While the scan runs
56
- the Map shows its progress. Under the graph is the [experimental](#screens-blueprints-for-screen-automation)
57
- **Screens** view.
58
- - **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation)
59
- side by side: a card for each page (your inbox, GitHub, WhatsApp, any other) with
60
- how many new since yesterday (or in the last 3 or 7 days), its latest brief, the
61
- newest items, and **Open**, **Draft a reply** and **Check now** on them. **Seen**
62
- on a card sets its count back to 0 (what it found stays under Watching, and the
63
- other cards keep theirs). **💬** on a card opens a chat with your AI about what's
64
- new there: what needs you, and what to say to whom. Agree it there, then **Draft
65
- a reply** on one, and your agent gets that talk with the message, so it writes
66
- what you agreed. The tab shows the count, so you see what's waiting from
67
- any tab, and once you watch something the app opens on the Dashboard instead of
68
- the Map. `symbiot watch board` prints the same cards as JSON, and
44
+ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of these parts opens from it:
45
+
46
+ - **Map** — your repos and project folders as droplets in the liquid, placed by
47
+ their **nearest neighbours**: each repo's fingerprint is what it's built with
48
+ (languages and tools), the weeks you work on it (your commits per week over the
49
+ last 12), and what it's about (the words of its name, README and package
50
+ description, TF-IDF weighted), and it sits by the 3 repos most like it
51
+ (`mapknn.mjs`). The closest merge into one shape, the next are joined by a thin
52
+ thread of liquid, the rest by a faint line; each cluster is named over its region
53
+ ("JavaScript · Node"); what you haven't touched in a while is smaller and sinks;
54
+ **you are here** marks where your recent work is. **Point at a repo** to light up
55
+ its neighbours and see why ("shares JavaScript, Node; you worked on both the same
56
+ weeks"). **Click** for its details: branch, commits, uncommitted work, your last
57
+ 12 weeks, **Most like it**, a **Suggest next steps** button, and an **AI review**
58
+ (what the project does, whether it needs new work at all, and ideas you can tick
59
+ into **Tasks**). Scroll to zoom, drag to pan. Under the map: the languages and
60
+ tools you build with. The Map needs **no AI key**; reviews and suggestions use
61
+ your chosen model. Project folders that **aren't git repos** show up too (see
62
+ [Where it looks](#where-it-looks)), as rings. The [experimental](#screens-blueprints-for-screen-automation)
63
+ **Screens** view folds away under the map.
64
+ - **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation),
65
+ as a stream: time runs left to right, one current per feed (your inbox, GitHub,
66
+ WhatsApp, any other page), "now" on the right where each feed pools. Every message
67
+ is a bead placed when it arrived; the ones waiting for your reply are bigger and
68
+ amber, and what you've already seen stays as small faint beads. Point at or tap a
69
+ bead to read it and **Draft a reply** or **Open** it. Under each feed's name:
70
+ **Seen** (sets its count back to 0; what it found stays under Watching, and the
71
+ other feeds keep theirs), **Talk** (a chat with your AI about what's new there:
72
+ what needs you, and what to say to whom; agree it there, then **Draft a reply**
73
+ on one, and your agent gets that talk with the message, so it writes what you
74
+ agreed) and **Check now**. The headline says what's waiting; switch between 24
75
+ hours, 3 days and 7 days. Each feed's latest brief sits at the end of its
76
+ current, the cards are still there under **All of it as a list**, and
77
+ **Connections** below shows every site you can [link](#link-your-work).
78
+ `symbiot watch board` prints the same feeds as JSON, and
69
79
  `symbiot watch board --line` as one line for a status bar (`2 emails · 1 WhatsApp message`).
70
- `symbiot watch chat <id> "question"` is the card's 💬 chat from a terminal, so an
80
+ `symbiot watch chat <id> "question"` is a feed's Talk from a terminal, so an
71
81
  agent can go over what's new with you too.
72
82
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
73
83
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
74
- - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
84
+ - **Week / Standup / Todo** — the write-ups (these use your chosen AI), set as a
85
+ readable page with its dates; **Copy** copies the text as written.
75
86
  - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
76
87
  type or repo, then **Send to repos** to hand just those to your agent (see
77
88
  [`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
78
89
  finishes lands in **Awaiting your review** (see
79
90
  [Review and approve](#review-and-approve-the-agents-work)). A task you tick
80
- yourself is done and **auto-archives**; the archived view can restore it.
91
+ yourself is done and **auto-archives**; the archived view (the liquid's
92
+ **Archive**) can restore it. Opening Tasks or Agents from the liquid shows the
93
+ work first: a sphere per task an agent is on, named in plain words, with small
94
+ spheres orbiting it while it works; what's done and waiting for your OK; and what's
95
+ waiting its turn, gathered round the agent in its repo. One **Go** (or saying
96
+ "go") starts everything waiting. Tap a sphere for the details.
81
97
  **💬** on any task opens a Q&A thread: ask what it means, how to approach it, or
82
98
  (once it's awaiting review) what the agent changed. Answers use your chosen AI,
83
99
  grounded in that repo's commits, README, rules and pending diff; the thread is
@@ -475,13 +491,39 @@ in the app.
475
491
 
476
492
  ## The liquid: a home that shapes itself to you
477
493
 
478
- `symbiot app` opens on one surface of liquid silver. Its droplets are what only
479
- you can do (an Approve waiting, an agent's question to you), what's new on what
480
- you watch, and the parts of the app. Open a droplet and that part pools open over
481
- the liquid; **Sink back** (or Esc) returns it. Talk to it in the bar at the bottom:
482
- "open tasks" opens Tasks, anything else goes to the same Symbiot as every chat.
483
- Leave it alone and it rests as one orb. There are no settings: it adapts to you.
484
-
494
+ `symbiot app` opens on one surface of liquid. Pick its look at the top, next to the
495
+ name: **Ferrofluid** (glossy black liquid metal, lit like a studio; the default),
496
+ **Glass** (clear droplets that bend the colours behind them) or **Pearl** (silver
497
+ lit like a product photo). Every panel follows the look, and the typeface (Geist)
498
+ ships with Symbiot, so it looks the same offline.
499
+
500
+ Its droplets are what only you can do (an Approve waiting, an agent's question to
501
+ you), what's new on what you watch, and the parts of the app, grouped the
502
+ **P.A.R.A.** way, each group in its own part of the screen with its name over it:
503
+
504
+ - **Projects** (work with an end): Tasks, Agents, Todo and what's asking you. They
505
+ move the most; Agents has spheres orbiting it while an agent works. Click the
506
+ **Projects** name for your projects themselves: a sphere per repo with work on it,
507
+ saying what's going on there, amber when its work waits for your OK; click one for
508
+ just its tasks.
509
+ - **Areas** (what you keep up with): the Dashboard, your feeds, Week and Standup.
510
+ - **Resources** (to look things up in): Map, Drift and Settings.
511
+ - **Archive** (what's done): your archived tasks. It sits still, low on the right.
512
+
513
+ **Click a droplet** and its part opens over the liquid; click a group's name to open
514
+ that group. **Right-click** (or your mouse's back button) goes back one step at a
515
+ time; **Close** or Esc closes a part. In a text box, or over text you've selected,
516
+ right-click still gives the usual menu. Whatever needs you glows **amber**, here and
517
+ on the Dashboard. Talk to it in the bar at the bottom: "open tasks" opens Tasks,
518
+ "go" in the work view starts what's waiting, anything else goes to the same Symbiot
519
+ as every chat. Leave it alone and it rests as one orb. There are no settings for any
520
+ of this: it adapts to you.
521
+
522
+ - **Where things sit: nearest neighbours.** Inside a group, the parts you move
523
+ between most sit together (k-nearest neighbours on the same Markov chain as
524
+ below, counting only links stronger than chance), so the layout has clusters and
525
+ gaps instead of a ring. Each droplet keeps a steady offset of its own, so it
526
+ reads as grown, not ruled, and stays where it was from one visit to the next.
485
527
  - **How you work** (`adapt.mjs`). Use decays with a 3-day half-life; what you open
486
528
  next comes from a Markov chain (with a Dirichlet prior, so a few moves can't
487
529
  swing it) and your hour of day. The more predictable you are (low Shannon
@@ -503,7 +545,7 @@ Symbiot's config folder, readable by you only.
503
545
 
504
546
  ## Link your work
505
547
 
506
- Settings → **Link your work** has one button per standard work site: Gmail,
548
+ **Connections** (on the Dashboard, and in Settings) has one entry per standard work site: Gmail,
507
549
  Outlook, Google and Outlook Calendar, GitHub, GitLab, Slack, Microsoft Teams,
508
550
  WhatsApp, Jira & Confluence, Linear, Asana, Trello, Google Drive, Notion,
509
551
  HubSpot and Salesforce. One click:
@@ -517,8 +559,9 @@ HubSpot and Salesforce. One click:
517
559
  in Standup's "Waiting on you", and in **Week**. Someone who doesn't write code
518
560
  gets a Week from their linked sites too.
519
561
 
520
- Each button shows where it stands: grey (not linked), amber (sign in, or signed
521
- out), green (linked), with ↻ to check now and × to unlink. Unlinking stops
562
+ They're grouped by kind (Mail, Calendar, Code, Chat, Work, Docs, Sales), each with
563
+ a bead that shows where it stands: hollow (not linked), amber (sign in, or signed
564
+ out), solid (linked). Point at one for **Link**, **↻** (check now) and **Unlink**. Unlinking stops
522
565
  watching it and stops trusting what the link trusted, never a site you trusted
523
566
  yourself.
524
567
 
@@ -857,7 +900,7 @@ symbiot watch new --hours 24 # what's new, newest first (JSON)
857
900
  symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
858
901
  symbiot watch board --line # the same as one line: "2 emails · 1 WhatsApp message" (empty when nothing's new)
859
902
  symbiot watch seen <watch id> # set a card back to 0, like its Seen button
860
- symbiot watch chat <watch id> "what needs me?" # talk a card over with your AI, like its 💬 (--clear starts over)
903
+ symbiot watch chat <watch id> "what needs me?" # talk a card over with your AI, like its Talk (--clear starts over)
861
904
  symbiot watch check # read them all now (JSON)
862
905
  symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
863
906
  symbiot watch brief on # your AI says what needs you (off to stop)
package/home.mjs CHANGED
@@ -91,7 +91,13 @@ function workScene({ deps = {} } = {}) {
91
91
  const ready = pending().filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length)).map((r) => ({ id: "ready:" + r.repo, repo: r.repo, count: (r.tasks || []).length }));
92
92
  const busy = new Set(running.map((r) => r.name)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
93
93
  const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 56), repo: t.repo, busy: busy.has(t.repo) }));
94
- return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy).length };
94
+ // Projects: each repo with work on it, what's going on there in one line's worth
95
+ const by = {}, at = (name) => (by[name] = by[name] || { repo: name, waiting: 0, ready: 0, running: null });
96
+ running.forEach((r) => { at(r.name).running = { doing: r.doing, progress: r.progress, ask: r.waiting }; });
97
+ ready.forEach((r) => { at(r.repo).ready = r.count || 1; });
98
+ open.forEach((t) => { at(t.repo).waiting++; });
99
+ const projects = Object.values(by).sort((a, b) => (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || (a.repo < b.repo ? -1 : 1));
100
+ return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy).length, projects: projects.slice(0, 10), projectCount: projects.length };
95
101
  }
96
102
  // Go: everything waiting goes to its repo's agent, the way Send to repos and an
97
103
  // agent per repo would: briefs written, an agent started in each (or queued
package/mapknn.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // The Map's nearest neighbours: which of your repos belong together, and why.
2
+ //
3
+ // Each repo (and project folder) gets a fingerprint from three things the scan
4
+ // already reads, none of which needs an AI:
5
+ // - its stack: the languages and tools it uses (Jaccard overlap);
6
+ // - when you work on it: your commits per week over the last 12 weeks (cosine),
7
+ // so two repos you worked on in the same weeks count as related;
8
+ // - what it's about: the words of its name, README and package description
9
+ // (TF-IDF cosine, so words every repo has count for little).
10
+ // Similarity is a weighted sum of the three. Each repo keeps its k nearest above a
11
+ // floor (k-NN), with the reasons; repos joined by strong links form clusters, named
12
+ // after what they share most. Vitality (how recently you worked on it) lets the Map
13
+ // size and sink repos by how alive they are.
14
+
15
+ const W = { stack: 0.4, weeks: 0.35, words: 0.25 };
16
+ const K = 3, FLOOR = 0.12, CLUSTER = 0.3, HALF_LIFE_DAYS = 21;
17
+ const STOP = new Set("a an and are as at be by can for from has have how if in into is it its of on or our so that the this to was we what when which will with you your use used using get set run runs make made new not but all any each one two more most also only than then there these those they them their just like via app apps code project projects repo repository readme install npm yarn pnpm license mit see docs file files src test tests build version".split(" "));
18
+
19
+ function words(text = "") {
20
+ const tf = new Map();
21
+ for (const w of String(text).toLowerCase().match(/[a-z][a-z0-9]{2,}/g) || []) if (!STOP.has(w)) tf.set(w, (tf.get(w) || 0) + 1);
22
+ return tf;
23
+ }
24
+ function jaccard(a, b) { if (!a.size && !b.size) return 0; let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i); }
25
+ function cosine(a, b) { let d = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return na && nb ? d / Math.sqrt(na * nb) : 0; }
26
+ function cosMap(a, b) { let d = 0, na = 0, nb = 0; for (const [k, v] of a) { na += v * v; if (b.has(k)) d += v * b.get(k); } for (const v of b.values()) nb += v * v; return na && nb ? d / Math.sqrt(na * nb) : 0; }
27
+
28
+ // repos: [{ id, name, langs, tools, weeks: [12 numbers, oldest first], text, last: "YYYY-MM-DD" }]
29
+ function fingerprints(repos) {
30
+ const docs = repos.map((r) => words(`${r.name || ""} ${r.name || ""} ${r.text || ""}`));
31
+ const df = new Map(); for (const d of docs) for (const k of d.keys()) df.set(k, (df.get(k) || 0) + 1);
32
+ const N = repos.length;
33
+ return repos.map((r, i) => {
34
+ const tfidf = new Map(); for (const [k, v] of docs[i]) tfidf.set(k, (1 + Math.log(v)) * (Math.log((N + 1) / (df.get(k) + 1)) + 1));
35
+ return { id: r.id, stack: new Set([...(r.langs || []), ...(r.tools || [])]), weeks: (r.weeks || []).slice(-12), words: tfidf };
36
+ });
37
+ }
38
+ function similarity(a, b) {
39
+ const stack = jaccard(a.stack, b.stack), weeks = a.weeks.length && b.weeks.length ? cosine(a.weeks, b.weeks) : 0, wd = cosMap(a.words, b.words);
40
+ const why = [];
41
+ const shared = [...a.stack].filter((x) => b.stack.has(x)).slice(0, 3);
42
+ if (shared.length) why.push("shares " + shared.join(", "));
43
+ if (weeks >= 0.5) why.push("you worked on both the same weeks");
44
+ if (wd >= 0.15) { const top = [...a.words.keys()].filter((k) => b.words.has(k)).sort((x, y) => a.words.get(y) * b.words.get(y) - a.words.get(x) * b.words.get(x)).slice(0, 2); if (top.length) why.push("both about " + top.join(" and ")); }
45
+ return { sim: +(W.stack * stack + W.weeks * weeks + W.words * wd).toFixed(3), why };
46
+ }
47
+ function vitality(last, now = Date.now()) { const t = Date.parse(last || ""); if (!t) return 0; const days = Math.max(0, (now - t) / 86400000); return +Math.pow(0.5, days / HALF_LIFE_DAYS).toFixed(3); }
48
+
49
+ function mapKnn(repos, { k = K, now = Date.now() } = {}) {
50
+ const fp = fingerprints(repos), byId = {};
51
+ const neighbours = {}, edges = [], seen = new Set();
52
+ fp.forEach((a, i) => {
53
+ const near = fp.map((b, j) => (i === j ? null : { id: b.id, ...similarity(a, b) })).filter((x) => x && x.sim >= FLOOR).sort((x, y) => y.sim - x.sim || (x.id < y.id ? -1 : 1)).slice(0, k);
54
+ neighbours[a.id] = near;
55
+ for (const n of near) { const key = [a.id, n.id].sort().join("|"); if (!seen.has(key)) { seen.add(key); edges.push({ a: a.id, b: n.id, sim: n.sim }); } }
56
+ });
57
+ // clusters: strong links, joined (union-find); named after the stack they share most
58
+ const par = {}; const root = (x) => (par[x] === x ? x : (par[x] = root(par[x])));
59
+ for (const r of repos) par[r.id] = r.id;
60
+ for (const e of edges) if (e.sim >= CLUSTER) par[root(e.a)] = root(e.b);
61
+ const groups = {}; for (const r of repos) (groups[root(r.id)] = groups[root(r.id)] || []).push(r);
62
+ const clusters = Object.values(groups).filter((g) => g.length > 1).map((g) => {
63
+ const count = new Map(); for (const r of g) for (const s of new Set([...(r.langs || []), ...(r.tools || [])])) count.set(s, (count.get(s) || 0) + 1);
64
+ const label = [...count].filter(([, c]) => c >= 2).sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)).slice(0, 2).map(([s]) => s).join(" · ");
65
+ return { ids: g.map((r) => r.id), label: label || g.map((r) => r.name).slice(0, 2).join(" & ") };
66
+ }).sort((x, y) => y.ids.length - x.ids.length);
67
+ for (const r of repos) byId[r.id] = vitality(r.last, now);
68
+ return { neighbours, edges, clusters, vitality: byId };
69
+ }
70
+
71
+ export { mapKnn, similarity, fingerprints, vitality, words, W as KNN_WEIGHTS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.49.1",
3
+ "version": "0.50.0",
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": {
@@ -34,7 +34,8 @@
34
34
  "README.md",
35
35
  "CHANGELOG.md",
36
36
  "LICENSE",
37
- "fonts"
37
+ "fonts",
38
+ "mapknn.mjs"
38
39
  ],
39
40
  "engines": {
40
41
  "node": ">=18"
@@ -67,7 +68,7 @@
67
68
  "@anthropic-ai/sdk": "^0.128.0"
68
69
  },
69
70
  "scripts": {
70
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/adapt.mjs && node test/work.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
71
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
71
72
  "lint": "eslint ."
72
73
  },
73
74
  "devDependencies": {
package/scan.mjs CHANGED
@@ -9,6 +9,7 @@ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
9
9
  import { VERSION, loadConfig, saveConfig, sh, repoState } from "./core.mjs";
10
10
  import { detectHandoffs } from "./agents.mjs";
11
11
  import { PROVIDERS, resolveProvider } from "./ai.mjs";
12
+ import { mapKnn } from "./mapknn.mjs";
12
13
 
13
14
  const MAX_COMMITS = 140;
14
15
 
@@ -304,6 +305,14 @@ function findAllRepos(base) {
304
305
  return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
305
306
  } finally { scanEnd(own); }
306
307
  }
308
+ // What a project is about, in its own words: its package description and keywords
309
+ // and the start of its README (the Map's neighbours read the words, nothing else).
310
+ function aboutText(path) {
311
+ let t = "";
312
+ try { const p = JSON.parse(readFileSync(join(path, "package.json"), "utf8")); t += " " + (p.description || "") + " " + (Array.isArray(p.keywords) ? p.keywords.join(" ") : ""); } catch {}
313
+ for (const f of ["README.md", "readme.md", "README"]) { try { t += " " + readFileSync(join(path, f), "utf8").slice(0, 3000); break; } catch {} }
314
+ return t.slice(0, 4000);
315
+ }
307
316
  function detectRepo(r) {
308
317
  const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
309
318
  const count = {}; const tools = new Set();
@@ -318,7 +327,10 @@ function detectRepo(r) {
318
327
  const mine = Number(sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --oneline 2>/dev/null | wc -l`).trim()) || 0;
319
328
  const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
320
329
  const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
321
- return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
330
+ // for the Map's neighbours (mapknn.mjs): your commits per week, 12 weeks, oldest first; and what it's about
331
+ const weeks = Array(12).fill(0), now = Date.now();
332
+ for (const t of sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --since=84.days --format=%ct 2>/dev/null`).split("\n").filter(Boolean)) { const w = Math.floor((now - Number(t) * 1000) / (7 * 86400000)); if (w >= 0 && w < 12) weeks[11 - w]++; }
333
+ return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last, weeks, text: aboutText(r.path) };
322
334
  }
323
335
  // Non-git PROJECT folders inside your scan roots (you added them = consent):
324
336
  // a directory with a manifest but no .git — "not everything is a repo".
@@ -419,7 +431,7 @@ async function buildMapScan() {
419
431
  for (const r of repos) {
420
432
  const rid = "repo:" + r.path;
421
433
  add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
422
- meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools, files: r.files, branch: r.branch, last: r.last, path: r.path } });
434
+ meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools, files: r.files, branch: r.branch, last: r.last, path: r.path, weeks: r.weeks } });
423
435
  edges.push({ source: "me", target: rid });
424
436
  for (const L of r.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: rid, target: id }); }
425
437
  for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
@@ -474,7 +486,12 @@ async function buildMapScan() {
474
486
  edges.push({ source: "me", target: id }); extra++;
475
487
  }
476
488
  } catch {}
477
- const out = { nodes, edges, stats: {
489
+ // the Map's nearest neighbours (mapknn.mjs): repos and project folders, by stack, weeks and words
490
+ let knn = null;
491
+ try { const byPath = new Map(repos.map((r) => [r.path, r]));
492
+ knn = mapKnn(nodes.filter((n) => n.type === "repo" || n.type === "folder").map((n) => { const m = n.meta || {}, r = byPath.get(m.path) || {};
493
+ return { id: n.id, name: n.label, langs: m.langs || [], tools: m.tools || [], weeks: r.weeks || [], text: r.text != null ? r.text : (m.path ? aboutText(m.path) : ""), last: m.last || r.last || "" }; })); } catch {}
494
+ const out = { nodes, edges, ...(knn ? { knn } : {}), stats: {
478
495
  repos: repos.length,
479
496
  folders: nodes.filter((n) => n.type === "folder").length,
480
497
  languages: nodes.filter((n) => n.type === "lang").length,