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 +15 -0
- package/README.md +82 -39
- package/home.mjs +7 -1
- package/mapknn.mjs +71 -0
- package/package.json +4 -3
- package/scan.mjs +20 -3
- package/ui.mjs +448 -99
- package/watch.mjs +2 -1
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
|
-
|
|
45
|
-
|
|
46
|
-
- **Map**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
a
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|