diff --git a/README.md b/README.md index ac9b457..0b458f1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ network, no telemetry. [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)

- compartment dash on a 51,000-memory vault: memories by kind, growth over time, the relation graph + compartment dash on a 51,000-memory vault: growth over time and the relation graph

Compartment is persistent memory for AI agents, stored on your own computer. @@ -45,11 +45,13 @@ against that file. | Graphiti (Zep) / Letta | Neo4j / server + database | not documented | LLM key | LLM calls; telemetry on by default | | claude-mem | local SQLite + Chroma | not documented | sign-in required | account + provider calls; telemetry on by default | | basic-memory (AGPL) | Markdown + SQLite | not documented | none | telemetry on by default | +| Hindsight (Vectorize) | one container with embedded PostgreSQL | not documented | LLM key (local models configurable) | LLM calls; vendor states no telemetry | +| Supermemory | cloud service, or a self-hosted prebuilt binary | not documented | account (cloud) or LLM key (self-host) | cloud calls; self-host: vendor states no telemetry | +| Cognee | SQLite + LanceDB + Kuzu locally, or cloud | not documented | LLM key | LLM calls; telemetry on by default | +| MemOS | Neo4j + Qdrant self-hosted, or cloud | not documented | LLM key | LLM calls; telemetry on by default | ## The memory logic -The full write path is in [docs/MEMORY.md](docs/MEMORY.md). The main ideas: - **Almost everything is stored.** Only empty turns are dropped. A bare "OK" is a decision, not noise: when the agent asks *"Want me to send this reply to the client now?"* and the user answers *"OK"*, Compartment stores the @@ -300,32 +302,10 @@ and widens up to three times when filtering leaves too few. Below 20,000 records the vector search is exact (SIMD matrix math, recall 1.0); above that, HNSW at about 99% recall. -### Measured - -Against the previous scorer, end to end through `Vault.search`, on a real -6,705-memory vault with 44 queries in four families: - -| | before | after | -|---|---|---| -| Recall@1 | 0.523 | **0.773** | -| Recall@5 | 0.705 | **0.977** | -| MRR@10 | 0.601 | **0.845** | -| nDCG@10 | 0.627 | **0.878** | -| exact identifiers found in top 5 | 4/10 | **10/10** | -| facts past the encoder window | 0/6 | **5/6** | -| paraphrases | 16/16 | 16/16 | -| median search latency | 4.4 ms | 11.6 ms | - -Nothing regressed. The weights come from a sensitivity sweep and are -deliberately round: results are flat around them, because a ranker that only -works at `w_lex = 0.37` does not work. - ## The app and the dashboard

- The macOS panel: vault state, settings, connected agents, the last five memories -     - The same panel on Windows + The macOS panel: vault state, settings, connected agents, the last five memories

The same panel on each system: the **menu bar** on macOS, the @@ -342,16 +322,12 @@ memories. You can unlock, lock and change your passphrase there without a terminal. The app keeps no vault in memory; it reads state from the CLI, so it costs nothing when idle. It is meant to be one of the many apps on your computer, not something you have to learn: every function is a button or a -switch, and the defaults come from the measurements above. - -`compartment panel --login on | off | status` controls starting at login (on -Linux, the applications menu entry). `init --no-app` skips the app on -headless machines and in CI. +switch, and the defaults were chosen by measurement. -**`compartment dash`** opens the whole vault in your browser: memories by -kind, growth over time, the relation graph, tags, per-agent counts, live -search. It serves from RAM on 127.0.0.1 only, behind a random URL token, -read-only, with no outbound requests and no configuration. Ctrl-C closes it. +The **Dashboard** button opens the whole vault in your browser: growth over +time, the relation graph with every entity named, tags, per-agent counts and +live search. It is served from RAM on 127.0.0.1 only, read-only, with no +outbound requests.

compartment dash: namespaces, memories per agent, relation types, top tags and search @@ -371,42 +347,61 @@ whole suite under it on Linux, macOS and Windows. The full threat model, including what Compartment cannot protect against, is in [SECURITY.md](SECURITY.md). -You lock and unlock the vault yourself. +### From the app + +Everything you do day to day is a button. **Unlock** asks for your +passphrase; **Lock** closes the vault and clears every stored credential; +**Change password** rekeys it; **Auto-lock** chooses 15, 30 or 60 idle +minutes, or never. Compartment never generates a password, seed or recovery +phrase, and holds no credential you do not. + +After an unlock the vault stays open across processes, logouts and logins +for as long as you leave it, until a restart or power loss, until the +auto-lock timer fires, or until you lock it. A restart or power loss always +locks it: the unlock credential is the master key wrapped with a random +per-boot secret that lives only in kernel memory and is never written to +disk, so a new boot cannot open it. A copy of the credential file on its own +is useless. + +### From the command line -- **`compartment unlock`** opens the vault with your passphrase. Compartment - never generates a password, seed or recovery phrase, and holds no - credential you do not. (Vaults from older versions that were issued a - recovery phrase still accept it.) -- **`compartment lock`** closes it and clears every stored credential. - Agents can do the same with the `memory_lock` tool. +The same controls, plus two that only exist here: + +- **`compartment unlock`** and **`compartment lock`** do what the buttons do. + Agents can lock with the `memory_lock` tool. (Vaults from older versions + that were issued a recovery phrase still accept it.) - **`compartment 2fa enable`** adds a second factor: your passphrase plus a keyfile, for example on a USB stick. Both feed Argon2id together, so the requirement is enforced by the cryptography, not by a setting; a stolen vault file plus your passphrase opens nothing without the keyfile. The keyfile's location is remembered, so unlocking feels the same while it is present. +- **`compartment unlock --keychain`** on macOS is an explicit opt-in that + survives reboots. -After a normal unlock the vault stays open across processes, logouts and -logins for as long as you leave it, until a restart or power loss, until the -auto-lock timer fires (15, 30 or 60 idle minutes in the panel; `0` never), -or until you lock it. A restart or power loss always locks it: the unlock -credential is the master key wrapped with a random per-boot secret that -lives only in kernel memory and is never written to disk, so a new boot -cannot open it. A copy of the credential file on its own is useless. - -On macOS, `compartment unlock --keychain` is an explicit opt-in that survives -reboots. The `memory_unlock` MCP tool exists but is off by default, because -enabling it puts the passphrase in the model's context. +The `memory_unlock` MCP tool exists but is off by default, because enabling +it puts the passphrase in the model's context. ## One vault, many agents, any machine -Claude, Hermes Agent, Cursor and the CLI can use one vault at the same -time. Writes are serialised by an advisory file lock, every process detects -writes by others and reloads, and each caller has its own identity and -namespace with rw / ro / none grants, so a scratch agent can read without -writing. +### Without the command line + +Every agent on the machine uses the same vault, and none of that needs +setting up: the app's **Connect an agent** buttons wire Claude, Hermes Agent +and OpenClaw, and what one agent stores the others recall. Claude, Hermes +Agent, Cursor and the CLI can use the vault at the same time: writes are +serialised by a file lock, every process notices writes by others and +reloads, and each agent has its own identity and namespace. -A locked vault is one portable file: +A locked vault is one file, `memory.vault` in the `.compartment` folder of +your home directory. To move to another machine, lock the vault, copy the +file there, install Compartment and unlock it in the app with your +passphrase. + +### From the command line + +The same move, signed so the recipient can check it, plus the escape +hatches: ```bash compartment lock --sign @@ -418,7 +413,8 @@ compartment --vault memory.vault unlock # your passphrase (+ keyfile if 2FA) `compartment verify` and no credential. `export --plaintext` writes the vault as JSONL and `import` reads it back, so you are never locked in. [FORMAT.md](FORMAT.md) specifies the `.vault` and `.mpack` files byte by -byte. +byte. Per-agent namespaces take `rw`, `ro` or `none` grants in the settings +file, so a scratch agent can read without writing. **Memory packs** are signed, read-only bundles of curated memories (`compartment pack build | install | remove | list | export`). They install @@ -450,11 +446,18 @@ benchmark). ## Install +**No command line needed.** On a Mac, download **Compartment.pkg** from the +[latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest) +and open it. Python, the embedding model and every dependency are inside it. +It asks you to choose a passphrase, creates the vault, and puts Compartment +in your menu bar, where the **Connect an agent** buttons do the rest. + +From the command line, on any system: + | | | |---|---| | **pip** (macOS, Linux, Windows) | `pip install compartment && compartment init` | | **pipx / uv** | `pipx install compartment` or `uv tool install compartment`, then `compartment init` | -| **One click** (macOS) | open **Compartment.pkg** from the [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest). Python, the embedding model and every dependency are inside it | | **Claude Code plugin** | after `pip install compartment && compartment init`: `/plugin marketplace add MaxFreedomPollard/Compartment`, then `/plugin install compartment@maxfreedompollard`. Codex reads the same marketplace file | | **Docker** | `docker build -t compartment .` from a checkout; see [Wiring each agent](#wiring-each-agent) | @@ -487,9 +490,11 @@ installed in their skills directories. Any other MCP client uses this block ## Wiring each agent -Each of these is also a button in the app under **CONNECT AN AGENT**. On -Windows, run the same commands in PowerShell with -`py -m pip install compartment` in place of `pip install compartment`. +None of this needs a terminal: the **Connect an agent** buttons in the app +run the same steps for Claude, Hermes Agent and OpenClaw. The commands below +are for people who prefer them, and for wiring a client the app does not +list. On Windows, run them in PowerShell with `py -m pip install compartment` +in place of `pip install compartment`. **Claude (Code + Desktop)** @@ -608,6 +613,16 @@ Global flags, before the command: `--vault PATH`, `--caller NAME`, | `update` | upgrade in place. `--source` takes GitHub main, `--no-app` skips the restart | | `uninstall` | remove it. The vault is kept unless you pass `--purge` | +`compartment panel --login on | off | status` controls starting at login (on +Linux, the applications menu entry). `init --no-app` skips the app on +headless machines and in CI. + +`compartment dash` is the Dashboard button from the terminal: the whole vault +in your browser, growth over time, the relation graph with every entity +named, tags, per-agent counts, live search. It serves from RAM on 127.0.0.1 +only, behind a random URL token, read-only, with no outbound requests and no +configuration. Ctrl-C closes it. + ### The /compartmentalize skill `compartment integrate ` writes one file into that agent's own skills diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 0f7ffdf..50610eb 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -19,6 +19,9 @@ this file. | claude-mem | local SQLite + Chroma | not documented | sign-in required (email magic link), subscription after a trial for the hosted observer [5] | account + provider calls; usage telemetry on by default [9] | Apache-2.0 | | basic-memory | Markdown files + SQLite index | not documented | none locally | usage telemetry to Umami on by default, opt-out [10] | AGPL-3.0 | | Hindsight (Vectorize) | one container with embedded PostgreSQL | not documented | an LLM key (local models configurable) | LLM calls; vendor states no telemetry | MIT | +| Supermemory | cloud service, or a self-hosted prebuilt binary (the server source is not in the repository) | not documented for self-host; the cloud states AES-256 class encryption | an account (cloud) or an LLM key (self-host) | cloud calls; the vendor states the self-hosted binary sends no analytics [11] | MIT (repository) | +| Cognee | SQLite + LanceDB + Kuzu locally by default, or cloud | not documented | an LLM key (`LLM_API_KEY` is step two of the README) | LLM calls; usage telemetry on unless `TELEMETRY_DISABLED` is set [12] | Apache-2.0 | +| MemOS | Neo4j + Qdrant when self-hosted, or MemOS Cloud; a local plugin uses SQLite | not documented | an LLM key | LLM calls; the local plugin ships `telemetry: { enabled: true }` [13] | Apache-2.0 | Sources @@ -32,6 +35,9 @@ Sources 8. letta-code `src/telemetry/index.ts`: "Enabled by default unless explicitly disabled" (`LETTA_CODE_TELEM`, `DO_NOT_TRACK`). https://github.com/letta-ai/letta-code/blob/main/src/telemetry/index.ts 9. claude-mem `src/services/telemetry/consent.ts`: "Default: on (opt-out — anonymous events only)". https://github.com/thedotmack/claude-mem/blob/main/src/services/telemetry/consent.ts 10. basic-memory README, *Telemetry*: events go to Umami Cloud; `BASIC_MEMORY_NO_PROMOS` defaults to `false` and "disables promos and all telemetry". https://github.com/basicmachines-co/basic-memory#telemetry +11. Supermemory docs, *Self-Hosting Configuration*, section *Telemetry*: the self-hosted binary sends no analytics. https://supermemory.ai/docs/self-hosting/configuration#telemetry +12. `cognee/shared/utils.py`: events are posted to `https://test.prometh.ai` unless `TELEMETRY_DISABLED` is set in the environment. https://github.com/topoteretes/cognee/blob/main/cognee/shared/utils.py +13. `apps/memos-local-plugin/core/config/defaults.ts`: `telemetry: { enabled: true }`; the README's self-host path is `docker compose up` with Neo4j and Qdrant. https://github.com/MemTensor/MemOS/blob/main/apps/memos-local-plugin/core/config/defaults.ts What the table does not say diff --git a/docs/images/dashboard-graph.png b/docs/images/dashboard-graph.png index 7e35154..6419d25 100644 Binary files a/docs/images/dashboard-graph.png and b/docs/images/dashboard-graph.png differ diff --git a/docs/images/dashboard-tables.png b/docs/images/dashboard-tables.png index 47555c5..c47bee9 100644 Binary files a/docs/images/dashboard-tables.png and b/docs/images/dashboard-tables.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png index c913932..d3c79e1 100644 Binary files a/docs/images/dashboard.png and b/docs/images/dashboard.png differ diff --git a/src/compartment/dash.py b/src/compartment/dash.py index 010ff5b..5c372a1 100644 --- a/src/compartment/dash.py +++ b/src/compartment/dash.py @@ -36,9 +36,9 @@ TAG_HIDE_PREFIX = "id:" # seed ids would swamp the tag cloud -# Importance tiers (salience.py) → human buckets shown as "types of memories". -# The page's JS colours memory rows from this same list (it is injected into -# PAGE below), so the tier boundaries and the hex values cannot drift apart. +# Importance tiers (salience.py). The page's JS colours memory rows from this +# list (it is injected into PAGE below), so the tier boundaries and the hex +# values cannot drift apart. TYPE_BUCKETS = [ ("decisions & consent", 0.90, 1.01, "#e8b339"), ("personal facts & preferences", 0.80, 0.90, "#4fc3f7"), @@ -101,13 +101,6 @@ def snapshot_stats(v: Vault, caller: str = "dash") -> dict: f"WHERE {ns} UNION SELECT object_n FROM relations WHERE {ns})", nsp + nsp).fetchone()["c"] - types = [] - for label, lo, hi, color in TYPE_BUCKETS: - c = con.execute( - f"SELECT COUNT(*) c FROM records WHERE importance >= ? AND " - f"importance < ? AND {ns}", [lo, hi] + nsp).fetchone()["c"] - types.append({"label": label, "count": c, "color": color}) - # Bucket in LOCAL time: every other timestamp on the page is rendered # with the browser's locale, and a UTC bucket would let the last bar # and the newest "recent memory" disagree by a day. @@ -140,7 +133,7 @@ def snapshot_stats(v: Vault, caller: str = "dash") -> dict: "entities": n_entities, "quarantined": n_quar, "namespaces": [e for e in st["namespaces"] if e["namespace"] in allowed], - "types": types, "growth": growth, + "growth": growth, "tags": [{"tag": t, "count": c} for t, c in top_tags], "agents": [{"agent": a, "count": c} for a, c in top_agents], "predicates": [{"predicate": p, "count": c} for p, c in top_preds], @@ -152,7 +145,11 @@ def snapshot_stats(v: Vault, caller: str = "dash") -> dict: def snapshot_graph(v: Vault, caller: str = "dash", - max_edges: int = 400, max_nodes: int = 120) -> dict: + max_edges: int = 400, max_nodes: int = 60) -> dict: + # Every node is drawn as a labelled pill, so the graph shows the most + # connected entities rather than every entity: a name has to fit on the + # page to be worth drawing. total_entities lets the page say how many + # it left out. # Vault.relations would append an audit row per page load; the store's # own query is the same data with no write. ACL is applied here instead. with v._oplock: @@ -161,6 +158,10 @@ def snapshot_graph(v: Vault, caller: str = "dash", ns, nsp = _ns_clause(allowed) total = v.db.conn.execute( f"SELECT COUNT(*) c FROM relations WHERE {ns}", nsp).fetchone()["c"] + total_entities = v.db.conn.execute( + f"SELECT COUNT(*) c FROM (SELECT subject_n e FROM relations " + f"WHERE {ns} UNION SELECT object_n FROM relations WHERE {ns})", + nsp + nsp).fetchone()["c"] rels = [{"subject": r["subject"], "predicate": r["predicate"], "object": r["object"]} for r in v.db.query_relations(ns_in=allowed, limit=max_edges)] @@ -181,7 +182,7 @@ def snapshot_graph(v: Vault, caller: str = "dash", # total_relations is every relation the caller may read; the graph draws # at most max_edges of them, and only those between drawn entities. return {"nodes": nodes, "edges": edges, "total_relations": total, - "shown_relations": len(edges)} + "shown_relations": len(edges), "total_entities": total_entities} def snapshot_recent(v: Vault, caller: str = "dash", limit: int = 20) -> dict: @@ -457,19 +458,6 @@ def run(path: str, vault: Vault) -> None: color:var(--faint);margin-left:auto;font-size:11px} /* ---------- composition ---------- */ -.typebar{display:flex;height:14px;border-radius:999px;overflow:hidden; - background:var(--card2);margin-bottom:16px;border:1px solid var(--line)} -.typebar div{min-width:3px;transition:width .8s cubic-bezier(.2,.7,.2,1); - box-shadow:inset 0 0 6px rgba(255,255,255,.18)} -.typebar div+div{border-left:1.5px solid rgba(0,0,0,.45)} -.legend{display:flex;flex-wrap:wrap;gap:8px} -.lg{display:inline-flex;align-items:baseline;gap:7px;font-size:12px; - background:var(--card);border:1px solid var(--line);border-radius:12px; - padding:6px 12px} -.lg .sw{width:9px;height:9px;border-radius:3px;align-self:center; - box-shadow:0 0 8px var(--c)} -.lg b{font-variant-numeric:tabular-nums} -.lg .pct{color:var(--faint);font-size:11px} /* ---------- charts ---------- */ canvas{width:100%;display:block} @@ -482,7 +470,7 @@ def run(path: str, vault: Vault) -> None: #tip{position:absolute;pointer-events:none;display:none;z-index:2; background:rgba(16,20,28,.95);border:1px solid var(--line2); border-radius:10px;padding:7px 11px;font-size:12px;white-space:nowrap; - box-shadow:0 8px 24px rgba(0,0,0,.5)} + line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,.5)} #tip b{color:var(--cyan)} #tip .d{color:var(--faint);font-size:11px} @@ -543,11 +531,6 @@ def run(path: str, vault: Vault) -> None: served from RAM

-
-

What the vault remembers

-
-
-

Memories over time

@@ -629,18 +612,6 @@ def run(path: str, vault: Vault) -> None: `
${typeof n==="number"?fmt(n):n}
`+ `
${l}
`).join(""); } -function types(s){ - const total=s.types.reduce((a,b)=>a+b.count,0)||1; - $("typebar").innerHTML=s.types.map(t=> - `
`).join(""); - requestAnimationFrame(()=>requestAnimationFrame(()=>{ - [...$("typebar").children].forEach(d=>d.style.width=d.dataset.w+"%");})); - $("typelegend").innerHTML=s.types.map(t=> - `${esc(t.label)} - ${fmt(t.count)}${(100*t.count/total).toFixed(1)}%`).join(""); -} function growth(s){ const c=$("growth"),dpr=devicePixelRatio||1; const W=c.clientWidth,H=170;c.width=W*dpr;c.height=H*dpr; @@ -694,88 +665,113 @@ def run(path: str, vault: Vault) -> None: :'none yet'; } function graph(g){ + /* Every entity is drawn as a pill with its name inside: no unlabeled + shapes. Size and colour follow the number of relations. The server sends + the most connected entities (see snapshot_graph), so the picture stays + readable however large the vault. */ const c=$("graph"),dpr=devicePixelRatio||1; - const W=c.clientWidth,H=400;c.width=W*dpr;c.height=H*dpr; - c.style.height=H+"px"; + const W=c.clientWidth,n=g.nodes.length; + const H=Math.max(380,Math.min(760,120+n*10)); + c.width=W*dpr;c.height=H*dpr;c.style.height=H+"px"; const x=c.getContext("2d");x.scale(dpr,dpr); const total=g.total_relations===undefined?g.edges.length:g.total_relations; - $("graphcount").textContent=g.nodes.length? - (g.edges.lengthmemory_link, and you can add one yourself with "+ "compartment link \"Maya\" \"works at\" \"Acme\""; return;} - $("graphhint").innerHTML="node size & warmth = connectedness · hover for names"; - const maxDeg=Math.max(...g.nodes.map(n=>n.degree),1); - const N=g.nodes.map((n,i)=>({...n, - x:W/2+Math.cos(6.28*i/g.nodes.length)*Math.min(W,H)/3.2, - y:H/2+Math.sin(6.28*i/g.nodes.length)*H/3.2,vx:0,vy:0})); - const idx=Object.fromEntries(N.map((n,i)=>[n.id,i])); + $("graphhint").innerHTML="size and colour = number of relations · hover a name to list its relations"; + const maxDeg=Math.max(...g.nodes.map(v=>v.degree),1); + const trunc=s=>s.length>26?s.slice(0,25)+"…":s; + const font=fs=>`600 ${fs}px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`; + const N=g.nodes.map((v,i)=>{ + const fs=Math.round(11+4*Math.sqrt(v.degree/maxDeg)); + x.font=font(fs);const label=trunc(v.label); + const w=x.measureText(label).width+18,h=fs+12; + return {...v,label,fs,w,h,r:Math.sqrt(w*w+h*h)/2, + x:W/2+Math.cos(6.28*i/n)*Math.min(W,H)/3, + y:H/2+Math.sin(6.28*i/n)*H/3,vx:0,vy:0};}); + const idx=Object.fromEntries(N.map((v,i)=>[v.id,i])); const E=g.edges.map(e=>({a:idx[e.s],b:idx[e.o],p:e.p})); - const cap=v=>Math.max(-5,Math.min(5,v)); - for(let it=0;it<300;it++){ - for(let i=0;iMath.max(-6,Math.min(6,v)); + const clampIn=v=>{v.x=Math.max(v.w/2+4,Math.min(W-v.w/2-4,v.x)); + v.y=Math.max(v.h/2+4,Math.min(H-v.h/2-4,v.y));}; + for(let it=0;it<360;it++){ + for(let i=0;i{const a=N[e.a],b=N[e.b]; const dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)||1; - const f=Math.max(-2,Math.min(2,(d-95)*0.01)); + const f=Math.max(-2.5,Math.min(2.5,(d-(a.r+b.r+70))*0.012)); a.vx+=dx/d*f;a.vy+=dy/d*f;b.vx-=dx/d*f;b.vy-=dy/d*f;}); - N.forEach(n=>{n.vx+=(W/2-n.x)*0.008;n.vy+=(H/2-n.y)*0.008; - n.vx=cap(n.vx*0.6);n.vy=cap(n.vy*0.6); - n.x+=n.vx;n.y+=n.vy; - n.x=Math.max(18,Math.min(W-18,n.x)); - n.y=Math.max(18,Math.min(H-18,n.y));}); + N.forEach(v=>{v.vx+=(W/2-v.x)*0.006;v.vy+=(H/2-v.y)*0.006; + v.vx=cap(v.vx*0.6);v.vy=cap(v.vy*0.6);v.x+=v.vx;v.y+=v.vy;clampIn(v);}); + } + /* pills are rectangles: a last pass separates any that still overlap */ + for(let pass=0;pass<16;pass++){ + let moved=false; + for(let i=0;i0&&oy>0){moved=true; + if(ox{const t=Math.sqrt(deg/maxDeg); + const warm=deg=>{const t=Math.sqrt(deg/maxDeg); const r=Math.round(88+t*(232-88)),g2=Math.round(166+t*(179-166)), b=Math.round(255+t*(57-255)); return `rgb(${r},${g2},${b})`;}; + function pill(v,col,alpha,hl){ + x.globalAlpha=alpha; + const rr=v.h/2,X=v.x-v.w/2,Y=v.y-v.h/2; + x.beginPath();x.moveTo(X+rr,Y);x.lineTo(X+v.w-rr,Y); + x.arc(X+v.w-rr,Y+rr,rr,-Math.PI/2,Math.PI/2);x.lineTo(X+rr,Y+v.h); + x.arc(X+rr,Y+rr,rr,Math.PI/2,3*Math.PI/2);x.closePath(); + x.fillStyle=col;x.shadowColor=col;x.shadowBlur=hl?16:0;x.fill();x.shadowBlur=0; + if(hl){x.lineWidth=1.5;x.strokeStyle="#ffffff";x.stroke();} + x.font=font(v.fs);x.textBaseline="middle";x.textAlign="center"; + x.fillStyle="#0b0f16";x.fillText(v.label,v.x,v.y+0.5); + x.globalAlpha=1; + } + const linked=(i,j)=>E.some(e=>(e.a===i&&e.b===j)||(e.b===i&&e.a===j)); function draw(hover){ x.clearRect(0,0,W,H); - E.forEach(e=>{ - const a=N[e.a],b=N[e.b]; + E.forEach(e=>{const a=N[e.a],b=N[e.b]; const on=hover>=0&&(e.a===hover||e.b===hover); const mx=(a.x+b.x)/2+(a.y-b.y)*0.12,my=(a.y+b.y)/2+(b.x-a.x)*0.12; - x.strokeStyle=on?"rgba(126,224,255,.75)":"rgba(130,150,190,.20)"; - x.lineWidth=on?1.6:1; + x.strokeStyle=on?"rgba(126,224,255,.85)":"rgba(130,150,190,.32)"; + x.lineWidth=on?1.8:1; x.beginPath();x.moveTo(a.x,a.y);x.quadraticCurveTo(mx,my,b.x,b.y);x.stroke();}); - N.forEach((n,i)=>{ - const r=3.5+2.6*Math.sqrt(n.degree); - const col=warm(n.degree); - const dimmed=hover>=0&&i!==hover&& - !E.some(e=>(e.a===hover&&e.b===i)||(e.b===hover&&e.a===i)); - x.globalAlpha=dimmed?0.35:1; - x.beginPath();x.arc(n.x,n.y,r,0,7); - x.fillStyle=col;x.shadowColor=col;x.shadowBlur=i===hover?18:9; - x.fill();x.shadowBlur=0;x.globalAlpha=1;}); - const labeled=N.map((n,i)=>[n,i]).sort((a,b)=>b[0].degree-a[0].degree) - .slice(0,16).map(p=>p[1]); - x.font="11px -apple-system,sans-serif";x.textBaseline="middle"; - labeled.forEach(i=>{const n=N[i]; - if(hover>=0&&i!==hover)x.globalAlpha=0.55; - const r=3.5+2.6*Math.sqrt(n.degree); - x.lineWidth=3;x.strokeStyle="rgba(8,10,15,.85)"; - x.strokeText(n.label,n.x+r+5,n.y); - x.fillStyle=i===hover?"#7ee0ff":"#c9d2e3"; - x.fillText(n.label,n.x+r+5,n.y);x.globalAlpha=1;}); + N.map((v,i)=>i).sort((i,j)=>N[i].degree-N[j].degree).forEach(i=>{ + const dimmed=hover>=0&&i!==hover&&!linked(hover,i); + pill(N[i],warm(N[i].degree),dimmed?0.28:1,i===hover);}); } draw(-1); const tip=$("tip"); c.onmousemove=ev=>{const r=c.getBoundingClientRect(); - const mx=ev.clientX-r.left,my=ev.clientY-r.top;let best=-1,bd=230; - N.forEach((n,i)=>{const d=(n.x-mx)**2+(n.y-my)**2;if(d{if(Math.abs(v.x-mx)<=v.w/2+2&&Math.abs(v.y-my)<=v.h/2+2)best=i;}); draw(best); - if(best>=0){const n=N[best]; + if(best>=0){const v=N[best]; + const rel=E.filter(e=>e.a===best||e.b===best).slice(0,8).map(e=> + e.a===best?`→ ${esc(e.p)} ${esc(N[e.b].label)}` + :`${esc(N[e.a].label)} ${esc(e.p)} →`); tip.style.display="block"; - tip.style.left=Math.min(n.x+14,W-150)+"px"; - tip.style.top=(n.y-34)+"px"; - tip.innerHTML=`${esc(n.label)} · ${n.degree} - relation${n.degree===1?"":"s"}`; + tip.style.left=Math.min(v.x+v.w/2+8,W-260)+"px"; + tip.style.top=Math.max(4,v.y-v.h/2-8-18*(rel.length+1))+"px"; + tip.innerHTML=`${esc(v.label)} · ${v.degree} relation${v.degree===1?"":"s"}`+ + (rel.length?`
${rel.join("
")}
`:""); }else tip.style.display="none";}; c.onmouseleave=()=>{draw(-1);tip.style.display="none";}; } @@ -806,7 +802,7 @@ def run(path: str, vault: Vault) -> None: setBadge("indexbadge",s.index); setBadge("auditbadge",s.audit_ok?"audit chain verified":"AUDIT CHAIN BROKEN", s.audit_ok?"badge ok":"badge bad"); - tiles(s);types(s);growth(s); + tiles(s);growth(s); tablefill("nstable",s.namespaces.map(n=>[n.namespace,n.records])); tablefill("agtable",s.agents.map(a=>[a.agent,a.count])); chips("tagchips",s.tags,"tag","count"); diff --git a/src/compartment/menubar.py b/src/compartment/menubar.py index a17d9b9..c0a0562 100644 --- a/src/compartment/menubar.py +++ b/src/compartment/menubar.py @@ -25,7 +25,9 @@ import shutil import subprocess import sys +import threading import time +import webbrowser from pathlib import Path from . import __version__ @@ -398,6 +400,78 @@ def fetch_state(vault: str) -> dict: return state +#: The `compartment dash` started from the panel, if any. One per panel +#: process: a second click reopens the same page instead of starting a second +#: server with a second random token for the same vault. +_DASH: dict = {"proc": None, "url": None} +_DASH_PREFIX = "Compartment dashboard: " + + +def dashboard_url(line: str) -> str | None: + """The URL `compartment dash` announces on its first line, or None.""" + line = line.strip() + if line.startswith(_DASH_PREFIX): + return line[len(_DASH_PREFIX):].strip() or None + return None + + +def _drain(proc) -> None: + # `dash` says a few more lines over its life; read them so it can never + # block on a full pipe. + try: + for _ in proc.stdout: + pass + except (OSError, ValueError): + pass + + +def open_dashboard(vault: str) -> str | None: + """Open the vault's dashboard in the browser. + + Starts `compartment dash` if this panel has not already started one. + `dash` opens the browser itself the moment it is serving, so a first + click needs only a started process; a later click reopens the URL it + announced. Returns that URL, or None when the server did not start (a + locked vault, for instance, which `dash` refuses). + """ + proc, url = _DASH["proc"], _DASH["url"] + if proc is not None and proc.poll() is None and url: + webbrowser.open(url) + return url + try: + proc = subprocess.Popen( + [*_cli_argv(), "--vault", vault, "dash"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, + encoding="utf-8", errors="replace", + env={**os.environ, "PATH": user_path()}) + except (OSError, subprocess.SubprocessError): + return None + _DASH["proc"], _DASH["url"] = proc, None + url = None + for line in proc.stdout: # the announcement is line one + url = dashboard_url(line) + if url: + break + if not url: + stop_dashboard() + return None + _DASH["url"] = url + threading.Thread(target=_drain, args=(proc,), daemon=True).start() + return url + + +def stop_dashboard() -> None: + """End the dashboard this panel started, if it is still running.""" + proc = _DASH["proc"] + _DASH["proc"], _DASH["url"] = None, None + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + def lock_vault(vault: str) -> bool: return _run([*_cli_argv(), "--vault", vault, "lock"])[0] == 0 @@ -1638,8 +1712,17 @@ def newfield(placeholder): wrap=True)) views.append(divider()) - views.append(label(f"LAST {RECENT_COUNT} MEMORIES", 10, bold=True, - secondary=True)) + head = label(f"LAST {RECENT_COUNT} MEMORIES", 10, bold=True, + secondary=True) + if st["exists"] and not st["locked"]: + # The five here are a glance; the whole vault is a page. + dash_b = NSButton.buttonWithTitle_target_action_( + "Dashboard", self, "openDashboard:") + dash_b.setToolTip_("Open the whole vault in your browser: " + "growth, the relation graph, tags, search") + views.append(row(head, _spacer(), dash_b)) + else: + views.append(head) if not st["exists"]: views.append(label("nothing yet - create the vault above", 11, secondary=True)) @@ -1881,6 +1964,10 @@ def lockNow_(self, sender): self.unlock_note = None self.rebuild() + def openDashboard_(self, sender): + if open_dashboard(vault_path) is None: + _d("the dashboard did not start") + def unlockNow_(self, sender): if self.pw_field is None: return @@ -1915,6 +2002,7 @@ def saveChangePw_(self, sender): self.rebuild() def quitApp_(self, sender): + stop_dashboard() NSApp.terminate_(self) def togglePopover_(self, sender): diff --git a/src/compartment/systray.py b/src/compartment/systray.py index ef81d3b..50d006d 100644 --- a/src/compartment/systray.py +++ b/src/compartment/systray.py @@ -35,6 +35,7 @@ acquire_instance_lock, auto_lock_label, change_passphrase, claim_first_run, create_vault, default_vault, fetch_state, integrate, lock_vault, + open_dashboard, stop_dashboard, release_instance_lock, self_check, set_setting, starter_note, summarise, unlock_vault) @@ -1106,6 +1107,9 @@ def work(): command=lambda: (panel.update(changing=True, change_note=None), refresh())).pack(side="left", padx=6) + ttk.Button(buttons, text="Dashboard", + command=lambda: open_dashboard(vault_path) + ).pack(side="left", padx=6) ttk.Button(buttons, text="Quit", command=quit_app).pack(side="right") def build(win) -> None: @@ -1201,6 +1205,7 @@ def refresh() -> None: place(win) def quit_app() -> None: + stop_dashboard() icon = panel.get("icon") if icon is not None: try: @@ -1225,6 +1230,8 @@ def from_tray(fn): from_tray(lambda: (lock_vault(vault_path), panel.update(note=None), refresh()))), + pystray.MenuItem("Open dashboard", + from_tray(lambda: open_dashboard(vault_path))), pystray.Menu.SEPARATOR, pystray.MenuItem("Quit", from_tray(quit_app)), )) diff --git a/tests/test_dash.py b/tests/test_dash.py index eb80f20..5a25403 100644 --- a/tests/test_dash.py +++ b/tests/test_dash.py @@ -45,16 +45,13 @@ def test_page_and_stats(served): assert status == 200 and b"Compartment" in body and b"