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)
-
+
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 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.
@@ -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.length
memory_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"