@tpsdev-ai/flair 0.32.0 → 0.33.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/README.md CHANGED
@@ -6,55 +6,11 @@
6
6
 
7
7
  > **The identity and memory substrate for AI agents. Crypto-pinned. Federated. Self-hosted.**
8
8
 
9
- Every agent framework gives you chat history. None give you *identity*. Flair gives an agent three things that survive a restart and follow it between orchestrators:
10
-
11
- - **Identity** — an Ed25519 keypair. The agent signs every request. No shared secrets.
12
- - **Memory** — persistent knowledge with semantic search, embedded in-process. No API calls.
13
- - **Soul** — the personality, values and procedures that make it *that* agent.
14
-
15
- Self-hosted on [Harper](https://harper.fast) as a single process. No sidecars, no vector database, no embedding API.
16
-
17
- ```
18
- ┌──────────────────────────────────────────────────────────────────┐
19
- │ same agent, same memory, every harness │
20
- │ │
21
- │ Claude Code ─┐ │
22
- │ Cursor ─┤ │
23
- │ Codex CLI ─┼─[ flair-mcp ]─┐ │
24
- │ Gemini CLI ─┤ │ │
25
- │ Continue.dev ─┤ │ ┌──────────────────────┐ │
26
- │ Goose ─┘ ├─▶ │ Flair (self-hosted) │ │
27
- │ LangGraph ─[ langgraph-flair ]──│ Ed25519 / HNSW / │ │
28
- │ OpenClaw ─[ openclaw-flair ]──│ Soul + Memory │ │
29
- │ n8n ─[ n8n-nodes-flair ]──└──────────┬───────────┘ │
30
- │ Hermes ─[ hermes-flair ]─┘ │ federation │
31
- │ Pi agent ─[ pi-flair ]─┘ │ (hub/spoke) │
32
- │ ▼ │
33
- │ ┌──────────────────────┐ │
34
- │ │ Flair (Fabric hub) │ │
35
- │ └──────────────────────┘ │
36
- └──────────────────────────────────────────────────────────────────┘
37
- ```
38
-
39
- 11 harness surfaces today. Pick whichever you're shipping in; the memory layer doesn't care. **[Full integrations catalog →](docs/integrations.md)**
9
+ Every agent framework gives you chat history. None give you *identity*. Flair gives an agent three things that survive a restart and follow it between orchestrators: an **identity** it proves with an Ed25519 keypair, **memory** it searches by meaning rather than by keyword, and a **soul** — the personality, values and procedures that make it *that* agent.
40
10
 
41
11
  ## Quick start
42
12
 
43
- Two front doors. Pick by where your code already runs.
44
-
45
- ### Already on Harper? Load Flair as a component
46
-
47
- Flair *is* a Harper component. Deploy it into the instance your application already runs in and call its resources directly — `await h.post({ agentId, content })` is a **method call**, not a network call. No second service to operate, no HTTP round trip, and no key to distribute: a caller in the same process is already inside the trust boundary and names the agent it is acting as, per call.
48
-
49
- Adding it takes nothing away. The HTTP surface keeps serving MCP clients and remote agents exactly as before.
50
-
51
- **→ [Embedding Flair in a Harper app](docs/embedding-in-a-harper-app.md)** — the whole in-process contract, measured against a real instance: resolving the resource, the table-vs-resource distinction that decides whether your memories are scoped at all, N agents in one process, and registering agents with no shell on the node.
52
-
53
- ### Everywhere else — install the CLI
54
-
55
- A laptop, a VPS, an MCP client, or any language over HTTP. Needs **Node.js 22+** and a **user-writable npm global prefix**.
56
-
57
- > ⚠️ **Never `sudo npm install -g @tpsdev-ai/flair`.** A root-owned install can't write the embedding model into its own package directory, so semantic search silently degrades to keyword-only. `flair init` and `flair doctor` will warn you loudly. Use `nvm`, or point npm at your home directory: `npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`.
13
+ Runs on a laptop, a VPS, or anywhere Node does. Needs **Node.js 22+**.
58
14
 
59
15
  ```bash
60
16
  # 1. Install the CLI (no sudo)
@@ -79,19 +35,30 @@ Step 4 finds the memory you never keyword-matched:
79
35
 
80
36
  That trailing figure is a rank score, normalized so the top hit is always near 100% — ordering, not confidence.
81
37
 
82
- `flair init` installs and starts Harper, creates the agent's Ed25519 keypair, verifies semantic search actually works, wires every MCP client it detects (Claude Code, Cursor, Codex CLI, Gemini CLI) to `npx -y @tpsdev-ai/flair-mcp`, and runs a smoke test. Restart your MCP client afterwards, then ask the agent *"what do you remember about me?"*
38
+ `flair init` installs and starts Harper, creates the agent's Ed25519 keypair, verifies semantic search actually works, wires every MCP client it detects (Claude Code, Cursor, Codex CLI, Gemini CLI), and runs a smoke test. Restart your MCP client afterwards, then ask the agent *"what do you remember about me?"*
83
39
 
84
40
  > **Pass `--agent`.** A bare `flair init` bootstraps the instance and stops there — no agent, no keypair, no MCP wiring.
85
41
 
86
42
  Full walkthrough with expected output at every step: **[docs/quickstart.md](docs/quickstart.md)**.
87
43
 
44
+ ### One install, one binary
45
+
46
+ `npm install -g @tpsdev-ai/flair` puts a single command on your `PATH`: `flair`. That is the whole install, and it needs a **user-writable npm global prefix** — which is why step 1 says *no sudo*. A root-owned install can't write the embedding model into its own package directory, so semantic search silently degrades to keyword-only. Use `nvm`, or point npm at your home directory — `npm config set prefix ~/.npm-global`, then add `~/.npm-global/bin` to `PATH`. `flair init` and `flair doctor` both check for this and say so loudly.
47
+
48
+ Two different things get called "MCP" here, and you get them differently:
49
+
50
+ - **The server has an MCP surface built in.** `/mcp` is a JSON-RPC endpoint exposing 12 curated tools, guarded by OAuth bearer tokens. It ships inside the package — and it is **off by default**: until you set `FLAIR_MCP_OAUTH` *and* a public issuer (`FLAIR_MCP_ISSUER`, falling back to `FLAIR_PUBLIC_URL`), no `/mcp` route is registered and the path returns 404. No documented client setup uses it today.
51
+ - **What your MCP client actually talks to is a separate package** — `@tpsdev-ai/flair-mcp`, a stdio adapter — and that one is deliberately not installed globally. `flair init` writes `npx -y @tpsdev-ai/flair-mcp@<version>` into each client's config, pinned to the CLI's own version, so the client fetches it on demand and there is no second global package to keep in step.
52
+
53
+ `@tpsdev-ai/flair-client` is likewise its own package: add it to a project when you want to call Flair from your own code ([JavaScript / TypeScript](#javascript--typescript)).
54
+
88
55
  ### Where the agent's key lives
89
56
 
90
57
  `flair init --agent mybot` writes the private key to `~/.flair/keys/mybot.key` (mode `0600`) and the public half beside it as `mybot.pub`. Only the **public** key is registered on the instance; the private key never leaves the machine. `--keys-dir` writes both somewhere else.
91
58
 
92
59
  **Back that file up — it is the agent's identity, and there is one copy.** Memories are not encrypted with it, so losing it costs the identity, not the data: the agent can no longer sign, and every HTTP call it makes fails. Recovery is `flair agent rotate-key mybot`, which mints a new pair and re-registers the public half — it needs the admin password `flair init` wrote to `~/.flair/admin-pass`, so back that up too. Otherwise treat the key like an SSH key: one per agent per host, never copied between machines ([docs/secrets-and-keys.md](docs/secrets-and-keys.md)).
93
60
 
94
- **The in-process path needs no key at all.** Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance asserts identity through the call context instead `agentContext("mybot")` which Flair reads and acts on with no signature, no `Agent`-table lookup and no registration. That is deliberate: same-process code could write the storage tables directly, so demanding a signature from it would be theatre. It is also why that id must come from your own server-side state and never from request data.
61
+ Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance needs no key at allsee [Embedded in a Harper app](#embedded-in-a-harper-app-in-process).
95
62
 
96
63
  ### Useful flags
97
64
 
@@ -138,9 +105,35 @@ Write a memory, then find it by meaning. The same memory is visible to every har
138
105
 
139
106
  One Ed25519 identity, one memory store, three MCP-capable CLIs. A memory written from Claude Code is retrievable from Codex CLI and Gemini CLI a moment later. Identity and history aren't bound to one orchestrator's runtime.
140
107
 
108
+ ## One agent, every harness
109
+
110
+ ```
111
+ ┌──────────────────────────────────────────────────────────────────┐
112
+ │ same agent, same memory, every harness │
113
+ │ │
114
+ │ Claude Code ─┐ │
115
+ │ Cursor ─┤ │
116
+ │ Codex CLI ─┼─[ flair-mcp ]─┐ │
117
+ │ Gemini CLI ─┤ │ │
118
+ │ Continue.dev ─┤ │ ┌──────────────────────┐ │
119
+ │ Goose ─┘ ├─▶ │ Flair (self-hosted) │ │
120
+ │ LangGraph ─[ langgraph-flair ]──│ Ed25519 / HNSW / │ │
121
+ │ OpenClaw ─[ openclaw-flair ]──│ Soul + Memory │ │
122
+ │ n8n ─[ n8n-nodes-flair ]──└──────────┬───────────┘ │
123
+ │ Hermes ─[ hermes-flair ]─┘ │ federation │
124
+ │ Pi agent ─[ pi-flair ]─┘ │ (hub/spoke) │
125
+ │ ▼ │
126
+ │ ┌──────────────────────┐ │
127
+ │ │ Flair (Fabric hub) │ │
128
+ │ └──────────────────────┘ │
129
+ └──────────────────────────────────────────────────────────────────┘
130
+ ```
131
+
132
+ 11 harness surfaces today. Pick whichever you're shipping in; the memory layer doesn't care. **[Full integrations catalog →](docs/integrations.md)**
133
+
141
134
  ## How it works
142
135
 
143
- Flair is a native [Harper v5](https://harper.fast) application. Harper handles HTTP, persistence (RocksDB), and application logic in one process.
136
+ Flair is a native [Harper v5](https://harper.fast) application. Harper handles HTTP, persistence (RocksDB), and application logic in one process — self-hosted, with no sidecars, no vector database and no embedding API.
144
137
 
145
138
  ```
146
139
  Agent ──[Ed25519-signed request]──▶ Flair (Harper)
@@ -178,24 +171,22 @@ See **[DESIGN.md](DESIGN.md)** for the invariants behind the three primitives
178
171
  | **Web admin** | Server-rendered UI for principals, connectors, IdPs and instance config. No separate dashboard service. |
179
172
  | **Benchmarks** | [`flair-bench`](packages/flair-bench/README.md) scores candidate embedding models against the same recall corpus Flair's CI gates on. Runs standalone — no Flair install, no server. |
180
173
 
181
- Trust-graded recall reaches the authenticated HTTP API and the native `/mcp` tools today; CLI, `@tpsdev-ai/flair-client` and `flair-mcp` exposure is a follow-up. REM needs a configured generative backend (Ollama, OpenAI, Anthropic, …) — without one, `flair rem rapid` fails with `Reflection error: No generative backend configured`.
174
+ Trust-graded recall reaches the authenticated HTTP API and the built-in `/mcp` tools today — the latter being off by default, see [One install, one binary](#one-install-one-binary). CLI, `@tpsdev-ai/flair-client` and `flair-mcp` exposure is a follow-up. REM needs a configured generative backend (Ollama, OpenAI, Anthropic, …) — without one, `flair rem rapid` fails with `Reflection error: No generative backend configured`.
182
175
 
183
176
  ## How Flair compares
184
177
 
185
- | | Flair | Mem0 | Honcho | Letta (MemGPT) | Built-ins (OAI/Anthropic/Google) |
186
- |---|---|---|---|---|---|
187
- | **Identity model** | **Ed25519 per agent (crypto-pinned)** | tenant-isolation | per-user soft tenant | runtime-bound | account-scoped |
188
- | **Federation (peer-to-peer)** | **yes — hub/spoke validated** | no | no | no | no |
189
- | **Cross-orchestrator** | **11+ harnesses, same memory** | several | several | runtime-bound | vendor-locked |
190
- | **Soul / persistent character** | **first-class** | optional | persona-shaped | optional | no |
191
-
192
- Parity rows are omitted: Mem0, Honcho and Letta are all open-source, self-hostable, and do semantic search. Those are table stakes here.
178
+ Every product here does semantic recall over stored memories. These are the dimensions they actually differ on.
193
179
 
194
- The honest gaps if you need one of these specifically, use that tool:
180
+ | | Flair | Mem0 | Honcho | Letta (MemGPT) | [SageOx](https://sageox.ai) | Built-ins (OAI/Anthropic/Google) |
181
+ |---|---|---|---|---|---|---|
182
+ | **Where memories live** | infrastructure you run | self-host or Mem0 Cloud | self-host or hosted API | self-host or Letta Cloud | SageOx cloud | vendor cloud |
183
+ | **Memory is scoped to** | the agent, via an Ed25519 keypair | tenant / user | per-user tenant | the runtime | the team | the account |
184
+ | **Reaches other orchestrators** | 11 harnesses, incl. workflow and agent frameworks | several | several | Letta's runtime | 14+ coding agents and editors, via hooks, plugins and instruction files | no |
185
+ | **Sync between instances you run** | hub/spoke federation | no | no | no | one hosted service | one hosted service |
186
+ | **Captures in-person conversation** | no | no | no | no | yes — Ox Dot | no |
187
+ | **Per-agent persistent character** | first-class (Soul) | optional | persona-shaped | optional | team context, not per-agent | no |
195
188
 
196
- - Mem0's **cloud sync UX** is more polished, if you're happy with their hosting.
197
- - Honcho's **persona model** is more developed, if rich personality modeling is the priority.
198
- - Letta's **runtime integration** is tighter, if you're building on their agent loop.
189
+ **Where Flair loses.** [SageOx's Ox Dot](https://sageox.ai) records in-person meetings, standups and whiteboard sessions and pipes them into shared context; Flair has no ambient capture of anything that isn't already text in a tool. Mem0's hosted sync is more polished. Honcho's persona model is more developed. Letta's integration with its own agent loop is tighter than anything Flair offers. And there is no Flair-operated cloud — running it is your job.
199
190
 
200
191
  ## Integration
201
192
 
@@ -298,7 +289,11 @@ Sign `agentId:timestamp:nonce:METHOD:/path` with the agent's private key. Protoc
298
289
 
299
290
  ### Embedded in a Harper app (in-process)
300
291
 
301
- Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly a method call instead of an HTTP round trip, and no key anywhere.
292
+ The second front door, and the one to take if your code already runs on Harper.
293
+
294
+ Flair *is* a Harper component. Deploy it into the instance your application already runs in and call its resources directly — `await h.post({ agentId, content })` is a **method call**, not a network call. No second service to operate, no HTTP round trip, and no key to distribute: a caller in the same process is already inside the trust boundary and names the agent it is acting as, per call.
295
+
296
+ Adding it takes nothing away. The HTTP surface keeps serving MCP clients and remote agents exactly as before.
302
297
 
303
298
  ```javascript
304
299
  import { server } from "harper";
@@ -312,7 +307,11 @@ const h = await collectionResource(Memory, agentContext("mybot"));
312
307
  await h.post({ agentId: "mybot", content: "...", durability: "standard" });
313
308
  ```
314
309
 
315
- `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. `new Memory(...)` is not a substitute for `collectionResource` — a create needs a collection-bound instance only Harper can produce. Both helpers refuse a missing agent id rather than defaulting it, because a resource invoked with no context resolves to Flair's trusted `internal` verdict and runs unfiltered across every agent. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
310
+ `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. `new Memory(...)` is not a substitute for `collectionResource` — a create needs a collection-bound instance only Harper can produce. Both helpers refuse a missing agent id rather than defaulting it, because a resource invoked with no context resolves to Flair's trusted `internal` verdict and runs unfiltered across every agent.
311
+
312
+ **This path needs no key at all.** Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance asserts identity through the call context instead — `agentContext("mybot")` — which Flair reads and acts on with no signature, no `Agent`-table lookup and no registration. That is deliberate: same-process code could write the storage tables directly, so demanding a signature from it would be theatre. It is also why that id must come from your own server-side state and never from request data.
313
+
314
+ **→ [Embedding Flair in a Harper app](docs/embedding-in-a-harper-app.md)** — the whole in-process contract, measured against a real instance: resolving the resource, the table-vs-resource distinction that decides whether your memories are scoped at all, N agents in one process, and registering agents with no shell on the node.
316
315
 
317
316
  ### Auth across surfaces
318
317
 
@@ -357,6 +356,7 @@ Full model, threat analysis and recommendations in [SECURITY.md](SECURITY.md).
357
356
  - Ed25519 cryptographic identity — agents sign every request.
358
357
  - Writes are always agent-scoped. An agent can only write its own records.
359
358
  - Reads are open within the org: any agent can read any other agent's non-private memory, no grant required. `private` is the one owner-only exception ([DESIGN.md](DESIGN.md#access-model-open-within-the-org-closed-at-the-federation-edge)).
359
+ - Which memories are non-private is decided at write time, from durability: `permanent`/`persistent` default to `shared`, `standard`/`ephemeral` to `private`. A write that names neither is `standard`, so it lands `private`. Say what you mean with `--visibility shared|private` (CLI) or `visibility` (MCP / SDK); a write response names the visibility the record landed on, so it never has to be inferred.
360
360
  - The admin password is generated by `flair init` and written to `~/.flair/admin-pass` (mode 0600). The CLI prints the path, never the value. Prefer `--admin-pass-file` over `--admin-pass` so it stays out of `ps` and shell history.
361
361
  - Key rotation via `flair agent rotate-key`.
362
362
 
package/SECURITY.md CHANGED
@@ -69,6 +69,13 @@ centralized read-scope rule), not application logic:
69
69
  **Private memories are strictly owner-only** — a memory written with
70
70
  `visibility: private` is returned only to its author, never to another agent.
71
71
 
72
+ **A write that does not name a visibility gets one anyway.** The server derives
73
+ it from the memory's durability: `permanent`/`persistent` → `shared`,
74
+ `standard`/`ephemeral` → `private`. Durability itself defaults to `standard`,
75
+ so a write naming neither is stamped `private` and is owner-only. An explicit
76
+ `visibility` on the write always overrides the rule, and the response to a
77
+ create names the value the record actually landed on.
78
+
72
79
  ### Cross-Agent Access (within an org)
73
80
 
74
81
  Within an org, an agent reads every non-private memory directly — no grant
package/config.yaml CHANGED
@@ -6,6 +6,40 @@ rest: true
6
6
  # http:
7
7
  # port: 19926
8
8
 
9
+ # Harper does not read a component's `.env` implicitly — it only loads env
10
+ # files a component ASKS for, via this plugin. Without this block a `.env`
11
+ # sitting next to config.yaml is inert: the file is present and its values
12
+ # never reach `process.env`. That is exactly what a deployed instance hit —
13
+ # `FLAIR_PUBLIC_URL` was set in the deployed component's `.env` and OAuth
14
+ # discovery kept advertising a loopback issuer (flair#1005, #1000).
15
+ #
16
+ # MUST STAY FIRST. Config keys are iterated in file order by Harper's
17
+ # component loader, and each plugin's initial entry load is awaited before
18
+ # the next key is processed — so declaring this above `jsResource` is what
19
+ # guarantees `process.env` is populated before `dist/resources/*.js` are
20
+ # imported. Most consumers read `process.env` per request and would not care
21
+ # (resources/OAuth.ts, resources/AdminInstance.ts, resources/XAA.ts,
22
+ # resources/a2a-url.ts), but `resources/mcp-oauth.ts` decides at MODULE LOAD
23
+ # whether to mount `/mcp`; move this below `jsResource` and that decision is
24
+ # made against an env that has not been loaded yet.
25
+ #
26
+ # No `.env` is required, which is the case for essentially every local
27
+ # install: when the glob matches nothing the plugin never fires and emits
28
+ # nothing. Measured — a boot log with this block and no `.env` differs from
29
+ # one without the block only in the PID and in non-deterministic table-init
30
+ # ordering. (A MALFORMED declaration is loud, not silent: a pattern
31
+ # containing '..' produced both an `Ignoring invalid loadEnv files pattern`
32
+ # warning and a `Could not load component 'loadEnv'` error, which is the
33
+ # positive control for that silence.)
34
+ #
35
+ # Application variables only. Harper composes its OWN configuration before
36
+ # component `.env` files load, so Harper-level settings cannot be set this
37
+ # way; `HARPER_CONFIG` / `HARPER_DEFAULT_CONFIG` / `HARPER_SET_CONFIG` are
38
+ # refused at the injection point and warned about (harper#1513). Those
39
+ # belong in the process environment or harper-config.yaml.
40
+ loadEnv:
41
+ files: '.env'
42
+
9
43
  graphqlSchema:
10
44
  files: schemas/*.graphql
11
45
 
package/dist/cli.js CHANGED
@@ -9684,17 +9684,38 @@ program
9684
9684
  // PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
9685
9685
  // every process holding ANY socket on the port, so `flair stop` could kill
9686
9686
  // itself (leaving Flair running) or kill an unrelated client of it.
9687
+ //
9688
+ // Attribution guard (flair#915): the port is not an identity. Refuse to
9689
+ // SIGTERM a PID that cannot be attributed to this instance.
9687
9690
  try {
9688
9691
  const { execSync } = await import("node:child_process");
9689
9692
  const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9690
9693
  if (pids.length > 0) {
9691
- for (const pid of pids) {
9692
- try {
9693
- process.kill(pid, "SIGTERM");
9694
+ const dataDir = defaultDataDir();
9695
+ const harperPid = readHarperPid(dataDir);
9696
+ if (harperPid !== null && !pids.includes(harperPid)) {
9697
+ console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
9698
+ + `do not match this Flair instance (PID ${harperPid}). `
9699
+ + `Not stopping — cannot attribute the process to this instance. `
9700
+ + `Stop the process manually if it is not Flair.`);
9701
+ process.exit(1);
9702
+ }
9703
+ else if (harperPid === null) {
9704
+ console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
9705
+ + `but no PID file in data directory — not a running Flair instance. `
9706
+ + `Not stopping — cannot attribute the process to this instance. `
9707
+ + `Stop the process manually if it is not Flair.`);
9708
+ process.exit(1);
9709
+ }
9710
+ else {
9711
+ for (const pid of pids) {
9712
+ try {
9713
+ process.kill(pid, "SIGTERM");
9714
+ }
9715
+ catch { /* already gone */ }
9694
9716
  }
9695
- catch { /* already gone */ }
9717
+ console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
9696
9718
  }
9697
- console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
9698
9719
  }
9699
9720
  else {
9700
9721
  console.log("Flair is not running.");
@@ -9859,17 +9880,29 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9859
9880
  * wrong today whenever the port does not match.
9860
9881
  */
9861
9882
  function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9862
- if (resolve(dataDir) === defaultDataDir())
9863
- return;
9883
+ // (flair#915) Apply the attribution check for ALL data directories, not
9884
+ // just non-default ones. The default-dir bypass was the residual gap that
9885
+ // #910 left behind — it allowed an unattributed SIGTERM on the default
9886
+ // install's port. The old concern (false refusal when hdb.pid is missing)
9887
+ // is actually the RIGHT behavior: no PID file means we cannot attribute the
9888
+ // listener, so we refuse. That is safer than killing the wrong process.
9864
9889
  const expected = readHarperPid(dataDir);
9865
- if (expected !== null && listeningPids.includes(expected))
9890
+ // No PID file — Harper is not (or was not) running in this directory.
9891
+ // The port is stale or held by something else; refuse to SIGTERM it.
9892
+ if (expected === null) {
9893
+ throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
9894
+ + `${resolve(dataDir)}, so that is not a running instance. `
9895
+ + `Stopping by port alone would signal a process we cannot attribute. `
9896
+ + `If it is not Flair, stop it manually.`);
9897
+ }
9898
+ // PID file exists — the PID on the port must be Harper.
9899
+ if (listeningPids.includes(expected))
9866
9900
  return;
9867
- const why = expected === null
9868
- ? `no hdb.pid under that directory, so it does not look like a running instance`
9869
- : `its recorded PID ${expected} is not the process listening on ${port}`;
9870
- throw new Error(`refusing to stop the process listening on port ${port}: it cannot be attributed to ${resolve(dataDir)} (${why}). ` +
9871
- `Stopping by port alone would signal a different Flair instance. ` +
9872
- `Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
9901
+ throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
9902
+ + `is not the process listening on ${port}. `
9903
+ + `Stopping by port alone would signal a different instance. `
9904
+ + `Pass --port with the port ${resolve(dataDir)} actually serves, `
9905
+ + `or stop the process manually.`);
9873
9906
  }
9874
9907
  /**
9875
9908
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
@@ -10171,7 +10204,11 @@ program
10171
10204
  .option("--purge", "Also remove data and keys (destructive)")
10172
10205
  .action(async (opts) => {
10173
10206
  const platform = process.platform;
10174
- const port = readPortFromConfig() ?? DEFAULT_PORT;
10207
+ // Use the unified resolver: Harper's config > per-user config > default.
10208
+ // A default of 19926 that is "present but wrong" beats the actual port
10209
+ // Harper is serving on (flair#819). resolveHttpPort reads Harper's own
10210
+ // config in the data directory, which is authoritative.
10211
+ const port = resolveHttpPort({}, "address");
10175
10212
  // Stop first: remove launchd service(s) on macOS, then kill by port on
10176
10213
  // all platforms. Removes BOTH the new instance-scoped plist and a
10177
10214
  // pre-flair#693 legacy plist if present — uninstall's job is to purge
@@ -10198,51 +10235,88 @@ program
10198
10235
  // Kill any process still on the port (covers direct-start, no-service, or
10199
10236
  // failed unload). Listening sockets only, never our own PID — see
10200
10237
  // parseListeningPids (flair#800/flair#905).
10238
+ //
10239
+ // Guard (flair#917): refuse to SIGTERM a PID that cannot be attributed to
10240
+ // this Flair instance. A port is not an identity — something else can hold
10241
+ // it. Killing the wrong PID and then purging data is the whole bug.
10242
+ let refusedKill = false;
10201
10243
  try {
10202
10244
  const { execSync } = await import("node:child_process");
10203
10245
  const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10204
10246
  if (pids.length > 0) {
10205
- for (const pid of pids) {
10206
- try {
10207
- process.kill(pid, "SIGTERM");
10247
+ // Verify ownership before killing: the PID must match this instance's
10248
+ // recorded PID (hdb.pid). If no PID file exists, Harper is already
10249
+ // stopped and the port is stale — safe to skip.
10250
+ const dataDir = defaultDataDir();
10251
+ const harperPid = readHarperPid(dataDir);
10252
+ if (harperPid !== null) {
10253
+ // PID file exists — the PID on the port must be Harper or we refuse.
10254
+ if (!pids.includes(harperPid)) {
10255
+ console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
10256
+ + `do not match this Flair instance (PID ${harperPid}). `
10257
+ + `Not killing — cannot attribute the process to this instance. `
10258
+ + `Stop the process manually if it is not Flair.`);
10259
+ refusedKill = true;
10208
10260
  }
10209
- catch { }
10261
+ else {
10262
+ for (const pid of pids) {
10263
+ try {
10264
+ process.kill(pid, "SIGTERM");
10265
+ }
10266
+ catch { }
10267
+ }
10268
+ await new Promise(r => setTimeout(r, 2000));
10269
+ console.log("✅ Flair process stopped");
10270
+ }
10271
+ }
10272
+ else {
10273
+ // No PID file — Harper is not (or was not) running here.
10274
+ // The port may be stale or held by something else; don't risk killing it.
10275
+ console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
10276
+ + `but no PID file in data directory — not a running Flair instance. `
10277
+ + `Not killing — stop the process manually if it is not Flair.`);
10278
+ refusedKill = true;
10210
10279
  }
10211
- // Wait for process to release file handles (RocksDB)
10212
- await new Promise(r => setTimeout(r, 2000));
10213
- console.log("✅ Flair process stopped");
10214
10280
  }
10215
10281
  }
10216
10282
  catch { /* not running */ }
10217
- // Remove config
10218
- const cfgPath = configPath();
10219
- if (existsSync(cfgPath)) {
10220
- const { unlinkSync } = await import("node:fs");
10221
- unlinkSync(cfgPath);
10222
- console.log("✅ Config removed");
10283
+ // Always remove per-user config on uninstall.
10284
+ {
10285
+ const cfgPath = configPath();
10286
+ if (existsSync(cfgPath)) {
10287
+ const { unlinkSync } = await import("node:fs");
10288
+ unlinkSync(cfgPath);
10289
+ console.log("✅ Config removed");
10290
+ }
10223
10291
  }
10224
10292
  if (opts.purge) {
10225
- const { rmSync } = await import("node:fs");
10226
- const dataDir = defaultDataDir();
10227
- const keysDir = defaultKeysDir();
10228
- const flairDir = join(homedir(), ".flair");
10229
- if (existsSync(dataDir)) {
10230
- rmSync(dataDir, { recursive: true, force: true });
10231
- console.log("✅ Data removed: " + dataDir);
10232
- }
10233
- if (existsSync(keysDir)) {
10234
- rmSync(keysDir, { recursive: true, force: true });
10235
- console.log("✅ Keys removed: " + keysDir);
10293
+ if (refusedKill) {
10294
+ console.log("\n⚠️ Skipping purge: could not attribute the process on port — data preserved.");
10295
+ console.log("Stop the process manually, then re-run: flair uninstall --purge");
10236
10296
  }
10237
- // Remove .flair dir if empty
10238
- try {
10239
- const { readdirSync, rmdirSync } = await import("node:fs");
10240
- if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
10241
- rmdirSync(flairDir);
10297
+ else {
10298
+ const { rmSync } = await import("node:fs");
10299
+ const dataDir = defaultDataDir();
10300
+ const keysDir = defaultKeysDir();
10301
+ const flairDir = join(homedir(), ".flair");
10302
+ if (existsSync(dataDir)) {
10303
+ rmSync(dataDir, { recursive: true, force: true });
10304
+ console.log("✅ Data removed: " + dataDir);
10242
10305
  }
10306
+ if (existsSync(keysDir)) {
10307
+ rmSync(keysDir, { recursive: true, force: true });
10308
+ console.log("✅ Keys removed: " + keysDir);
10309
+ }
10310
+ // Remove .flair dir if empty
10311
+ try {
10312
+ const { readdirSync, rmdirSync } = await import("node:fs");
10313
+ if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
10314
+ rmdirSync(flairDir);
10315
+ }
10316
+ }
10317
+ catch { /* non-empty, that's fine */ }
10318
+ console.log("\n🗑️ Flair fully purged");
10243
10319
  }
10244
- catch { /* non-empty, that's fine */ }
10245
- console.log("\n🗑️ Flair fully purged");
10246
10320
  }
10247
10321
  else {
10248
10322
  console.log("\nData and keys preserved at ~/.flair/");
@@ -10892,17 +10966,24 @@ program
10892
10966
  else if (versionCheckResult.latest) {
10893
10967
  console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
10894
10968
  }
10895
- // Helper: try to reach Harper on a given port
10969
+ // Helper: try to reach Harper on a given port.
10970
+ // Must return true ONLY when Harper's /Health endpoint returns 200 OK.
10971
+ // A generic HTTP status > 0 (flair#862) would accept 404 from a Node
10972
+ // inspector on 9229 or any other service — "present but wrong" beats
10973
+ // "absent but correct".
10896
10974
  async function probePort(p) {
10897
10975
  try {
10898
10976
  const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
10899
- return res.status > 0;
10977
+ return res.ok; // 200-299 only — /Health returns { ok: true } on 200
10900
10978
  }
10901
10979
  catch {
10902
10980
  return false;
10903
10981
  }
10904
10982
  }
10905
- // Helper: discover what port a Harper PID is listening on
10983
+ // Helper: discover what port a Harper PID is listening on.
10984
+ // Scans ALL listening ports for this PID and returns the first one that
10985
+ // responds to /Health with 200 OK. This avoids picking a debug port (9229)
10986
+ // or any non-Flair listener that happens to share the process (flair#862).
10906
10987
  async function discoverPortFromPid(pid) {
10907
10988
  // Defense-in-depth: caller already validates, but re-check here
10908
10989
  if (!/^\d+$/.test(pid))
@@ -10910,9 +10991,16 @@ program
10910
10991
  try {
10911
10992
  const { execSync } = await import("node:child_process");
10912
10993
  const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
10913
- const match = out.match(/:(\d+)$/m);
10914
- if (match)
10915
- return Number(match[1]);
10994
+ // Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
10995
+ const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
10996
+ if (ports.length === 0)
10997
+ return null;
10998
+ // Try each port until one responds to /Health with 200 OK
10999
+ for (const port of ports) {
11000
+ if (await probePort(port))
11001
+ return port;
11002
+ }
11003
+ return null; // No port responded to /Health
10916
11004
  }
10917
11005
  catch { /* ignore */ }
10918
11006
  return null;
@@ -12698,7 +12786,7 @@ memory.command("add [content]")
12698
12786
  .description("Write a new memory row for an agent (content via positional arg or --content)")
12699
12787
  .requiredOption("--agent <id>")
12700
12788
  .option("--content <text>", "memory content (alias for positional arg)")
12701
- .option("--durability <d>", "standard").option("--tags <csv>")
12789
+ .option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
12702
12790
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
12703
12791
  .option("--subject <text>", "one-line title / entity this memory is about")
12704
12792
  .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
@@ -12719,8 +12807,21 @@ memory.command("add [content]")
12719
12807
  body.summary = opts.summary;
12720
12808
  if (opts.subject)
12721
12809
  body.subject = opts.subject;
12722
- if (opts.visibility)
12723
- body.visibility = String(opts.visibility).trim();
12810
+ // flair#991: reject an unrecognized --visibility instead of writing it.
12811
+ // `visibility` is a free-form String server-side and the read scope asks
12812
+ // isPrivateVisibility() — an exact match on the literal "private" — so
12813
+ // ANY other string, `--visibility prvate` included, persists a row the
12814
+ // user believes is owner-only and that every agent on the instance can
12815
+ // in fact read. A typo must never widen who can read a memory.
12816
+ if (opts.visibility) {
12817
+ const visibility = String(opts.visibility).trim();
12818
+ if (visibility !== "private" && visibility !== "shared") {
12819
+ console.error(`error: --visibility must be 'private' or 'shared' (got: ${visibility})`);
12820
+ console.error(" omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private");
12821
+ process.exit(1);
12822
+ }
12823
+ body.visibility = visibility;
12824
+ }
12724
12825
  if (opts.derivedFrom) {
12725
12826
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
12726
12827
  }
@@ -13098,6 +13199,59 @@ function parseRelativeOrIso(input) {
13098
13199
  const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
13099
13200
  return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
13100
13201
  }
13202
+ export function searchScoringFormula(scoring) {
13203
+ return scoring === "composite"
13204
+ ? "semantic × durability-weight × recency-decay × usage-boost"
13205
+ : "cosine similarity only";
13206
+ }
13207
+ export function buildSearchExplain(record, scoring, now = Date.now()) {
13208
+ const score = typeof record?._score === "number" ? record._score : undefined;
13209
+ const rawScore = typeof record?._rawScore === "number" ? record._rawScore : undefined;
13210
+ // composite mode: server sends both (_rawScore = pre-composite semantic).
13211
+ // raw mode: server sends only _score, and that IS the raw score.
13212
+ const raw = scoring === "composite" ? rawScore : score;
13213
+ const composite = scoring === "composite" ? score : undefined;
13214
+ let ageDays;
13215
+ if (record?.createdAt) {
13216
+ const created = new Date(String(record.createdAt)).getTime();
13217
+ if (Number.isFinite(created))
13218
+ ageDays = Math.max(0, Math.floor((now - created) / 86_400_000));
13219
+ }
13220
+ const explain = {
13221
+ scoring,
13222
+ formula: searchScoringFormula(scoring),
13223
+ durability: record?.durability ?? "standard",
13224
+ usageCount: typeof record?.usageCount === "number" ? record.usageCount : 0,
13225
+ };
13226
+ if (typeof raw === "number")
13227
+ explain.raw = raw;
13228
+ if (typeof composite === "number")
13229
+ explain.composite = composite;
13230
+ if (typeof ageDays === "number")
13231
+ explain.ageDays = ageDays;
13232
+ return explain;
13233
+ }
13234
+ // Human one-liner for a hit's breakdown. Scoring terms come from the shared
13235
+ // builder; the trailing tags/subject/supersedes are record context that json
13236
+ // mode already carries at top level, so they're appended here only.
13237
+ export function formatSearchExplain(explain, record) {
13238
+ const parts = [];
13239
+ if (typeof explain.raw === "number")
13240
+ parts.push(`raw=${explain.raw.toFixed(3)}`);
13241
+ if (typeof explain.composite === "number")
13242
+ parts.push(`composite=${explain.composite.toFixed(3)}`);
13243
+ parts.push(`durability=${explain.durability}`);
13244
+ if (typeof explain.ageDays === "number")
13245
+ parts.push(`age=${explain.ageDays}d`);
13246
+ parts.push(`usage=${explain.usageCount}`);
13247
+ if (Array.isArray(record?.tags) && record.tags.length > 0)
13248
+ parts.push(`tags=[${record.tags.join(",")}]`);
13249
+ if (record?.subject)
13250
+ parts.push(`subject=${record.subject}`);
13251
+ if (record?.supersedes)
13252
+ parts.push(`supersedes=${record.supersedes}`);
13253
+ return parts.join(" · ");
13254
+ }
13101
13255
  program
13102
13256
  .command("search <query>")
13103
13257
  .description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
@@ -13120,7 +13274,7 @@ program
13120
13274
  .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
13121
13275
  .option("--source <name>", "Filter by source/agentId (client-side)")
13122
13276
  // Output modes
13123
- .option("--explain", "Show score breakdown (composite, raw, durability, age, retrieval) per hit")
13277
+ .option("--explain", "Show score breakdown (raw, composite, durability, age, usage) per hit — also added to --json output as _explain")
13124
13278
  .option("--json", "Output raw JSON array")
13125
13279
  .action(async (query, opts) => {
13126
13280
  try {
@@ -13180,8 +13334,17 @@ program
13180
13334
  results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
13181
13335
  }
13182
13336
  const mode = render.resolveOutputMode(opts);
13337
+ const scoringMode = payload.scoring === "composite" ? "composite" : "raw";
13183
13338
  if (mode === "json") {
13184
- console.log(render.asJSON(results));
13339
+ // flair#992: --explain must be honoured here, not silently dropped.
13340
+ // This branch is what every non-TTY caller lands in. The breakdown
13341
+ // rides ALONG the json contract as an opt-in `_explain` key — present
13342
+ // only when the caller typed --explain, so default output is unchanged
13343
+ // — rather than switching output mode behind the caller's back.
13344
+ const out = opts.explain
13345
+ ? results.map((r) => ({ ...r, _explain: buildSearchExplain(r, scoringMode) }))
13346
+ : results;
13347
+ console.log(render.asJSON(out));
13185
13348
  return;
13186
13349
  }
13187
13350
  if (results.length === 0) {
@@ -13233,30 +13396,15 @@ program
13233
13396
  if (meta)
13234
13397
  console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
13235
13398
  if (opts.explain) {
13236
- const parts = [];
13237
- if (typeof r._rawScore === "number")
13238
- parts.push(`raw=${r._rawScore.toFixed(3)}`);
13239
- if (typeof r._score === "number")
13240
- parts.push(`composite=${r._score.toFixed(3)}`);
13241
- if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
13242
- parts.push(`retrievals=${r.retrievalCount}`);
13243
- if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
13244
- parts.push(`tags=[${r.tags.join(",")}]`);
13245
- if (r.subject)
13246
- parts.push(`subject=${r.subject}`);
13247
- if (r.supersedes)
13248
- parts.push(`supersedes=${r.supersedes}`);
13249
- if (parts.length > 0) {
13250
- console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
13399
+ const line = formatSearchExplain(buildSearchExplain(r, scoringMode), r);
13400
+ if (line) {
13401
+ console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, line)}`);
13251
13402
  }
13252
13403
  }
13253
13404
  console.log();
13254
13405
  }
13255
13406
  if (opts.explain) {
13256
- const formula = payload.scoring === "composite"
13257
- ? "semantic × durability-weight × recency-decay × retrieval-boost"
13258
- : "cosine similarity only";
13259
- console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
13407
+ console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, scoringMode)} ${render.wrap(render.c.dim, `(${searchScoringFormula(scoringMode)})`)}`);
13260
13408
  }
13261
13409
  }
13262
13410
  catch (err) {
@@ -13404,7 +13552,7 @@ soul.command("set")
13404
13552
  .requiredOption("--agent <id>")
13405
13553
  .requiredOption("--key <key>")
13406
13554
  .requiredOption("--value <value>")
13407
- .option("--durability <d>", "permanent")
13555
+ .option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
13408
13556
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
13409
13557
  .action(async (opts) => {
13410
13558
  // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
@@ -243,8 +243,27 @@ async function runDedupGate(ctx, content) {
243
243
  return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
244
244
  }
245
245
  /** Build the final write response: always `written: true`, always includes
246
- * `id`, and layers the dedup collision signal on top when present. Never a
247
- * code path where a match suppresses these base fields. */
246
+ * `id`, `visibility`, and layers the dedup collision signal on top when
247
+ * present. Never a code path where a match suppresses these base fields.
248
+ *
249
+ * ── Why `visibility` is in the write response (flair#991) ──────────────────
250
+ * Visibility is the one field on a memory the caller most often does NOT
251
+ * set and yet most needs to know: the durability-keyed default above stamps
252
+ * `private` for a bare write and `shared` for a permanent/persistent one, so
253
+ * "who can read this" is decided by a rule the writer never typed. Returning
254
+ * it makes the landed value observable on EVERY write surface at once —
255
+ * `flair memory add`'s printed JSON, the REST response, the native /mcp
256
+ * `memory_store` result, and packages/flair-mcp's `effectiveVisibility` line
257
+ * (which read this field all along and had nothing to read, so it always
258
+ * rendered "(server default)").
259
+ *
260
+ * Read from `content`, not from `base`: `content.visibility` is the value
261
+ * that was actually persisted a few lines earlier, and assigning after the
262
+ * `...base` spread means the persisted value wins over anything the storage
263
+ * layer echoes back. Omitted (not `null`) when unset, which happens only on
264
+ * the put()-over-an-existing-record path where a partial merge carried no
265
+ * visibility — reporting `null` there would read as "no one but the owner",
266
+ * the opposite of what an absent field means to `isPrivateVisibility()`. */
248
267
  function buildWriteResponse(content, result, dedupMatch) {
249
268
  const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
250
269
  const response = {
@@ -253,6 +272,9 @@ function buildWriteResponse(content, result, dedupMatch) {
253
272
  written: true,
254
273
  deduplicated: !!dedupMatch,
255
274
  };
275
+ if (content.visibility !== undefined && content.visibility !== null) {
276
+ response.visibility = content.visibility;
277
+ }
256
278
  if (dedupMatch) {
257
279
  response.matchedId = dedupMatch.matchedId;
258
280
  response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
@@ -327,6 +327,7 @@ class InternalAgentTable {
327
327
  */
328
328
  export class Flair {
329
329
  #server;
330
+ #adminHandle;
330
331
  constructor(server) {
331
332
  this.#server = server;
332
333
  }
@@ -350,7 +351,10 @@ export class Flair {
350
351
  // AdminHandle requires an agentId for attribution. We use a sentinel
351
352
  // that makes the admin identity visible in audit logs. The caller
352
353
  // should use a real admin agent id when possible.
353
- return new AdminHandle(this.#server, "_admin");
354
+ // Cached: the getter returns the same handle on every access so
355
+ // flair.admin === flair.admin is true (flair#981).
356
+ this.#adminHandle ??= new AdminHandle(this.#server, "_admin");
357
+ return this.#adminHandle;
354
358
  }
355
359
  /**
356
360
  * Internal operations — trusted, unattributed, unfiltered.
@@ -159,6 +159,37 @@ async function memoryStore(agent, args) {
159
159
  // id post-commit through the shared usage ledger).
160
160
  if (Array.isArray(args?.usedMemoryIds))
161
161
  body.usedMemoryIds = args.usedMemoryIds;
162
+ // flair#991 writer-controlled sharing intent. Forwarded ONLY when the caller
163
+ // actually supplied it, so an omitted visibility delegates a byte-identical
164
+ // body and Memory.post() applies its durability-keyed default.
165
+ //
166
+ // ── Why an unrecognized value is REJECTED, not dropped and not passed on ──
167
+ // `visibility` is a free-form String in schemas/memory.graphql, and the read
168
+ // scope asks `isPrivateVisibility()` — an exact match on the literal
169
+ // "private" — so EVERY other string, typos included, reads as non-private
170
+ // and is returned to every agent on the instance. Both of the softer
171
+ // options therefore fail in the unsafe direction:
172
+ // - forwarding it: `visibility: "prvate"` persists a row the caller
173
+ // believes is owner-only and that every agent can in fact read;
174
+ // - silently dropping it: falls back to the durability-keyed default,
175
+ // which for a permanent/persistent write is `shared` — same outcome,
176
+ // with no argument left in the record to explain it.
177
+ // A misspelled argument must never widen who can read a memory, so the tool
178
+ // call fails and says so. The allowlist is deliberately not derived from
179
+ // isPrivateVisibility(): that predicate must stay "is it exactly private"
180
+ // for the no-visibility-field migration invariant (see
181
+ // resources/memory-visibility.ts), which is a READ-side rule and cannot
182
+ // double as a WRITE-side allowlist.
183
+ if (args?.visibility !== undefined && args?.visibility !== null) {
184
+ if (args.visibility !== "private" && args.visibility !== "shared") {
185
+ return {
186
+ error: "invalid_visibility",
187
+ status: 400,
188
+ message: `visibility must be "private" or "shared" (got: ${JSON.stringify(args.visibility)}). Omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private.`,
189
+ };
190
+ }
191
+ body.visibility = args.visibility;
192
+ }
162
193
  return unwrap(await h.post(body));
163
194
  }
164
195
  /**
@@ -407,6 +438,15 @@ export const TOOLS = {
407
438
  type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
408
439
  durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
409
440
  tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
441
+ visibility: {
442
+ type: "string",
443
+ enum: ["private", "shared"],
444
+ description: "Writer-controlled sharing intent. Omit to use the server's durability-keyed default: " +
445
+ "permanent/persistent -> shared, standard/ephemeral -> private. " +
446
+ "private — owner-only, never visible to another agent, even one holding a memory grant. " +
447
+ "shared — visible to the owner and every other agent on this instance. " +
448
+ "The visibility the write actually landed on is returned in the result.",
449
+ },
410
450
  usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
411
451
  },
412
452
  required: ["content"],
@@ -21,6 +21,7 @@ Embedding *adds* the in-process path; `rest: true` keeps serving MCP clients and
21
21
  **2. Import the facade and write a memory.**
22
22
 
23
23
  ```javascript
24
+ import { server } from "harper";
24
25
  import { Flair } from "@tpsdev-ai/flair";
25
26
 
26
27
  const flair = new Flair(server);
@@ -61,6 +62,8 @@ console.log([...server.resources.keys()].sort()); // what Flair registered
61
62
 
62
63
  One handle per Harper instance. Resolves resources lazily on first use — no lookup at construction time.
63
64
 
65
+ **The handle owns nothing.** It holds a reference to the Harper server the caller already owns and acquires no timers, connections, or file handles. There is no `close()` or `dispose()` method. If a future version acquires something releasable, that is a breaking change and will be versioned as one.
66
+
64
67
  ### `flair.as(agentId)`
65
68
 
66
69
  Returns an `AgentHandle` scoped to that agent. The `agentId` is runtime-validated: missing, empty, blank, or non-string throws `InProcessContextError`.
@@ -85,6 +88,8 @@ planner.agentId; // "planner"
85
88
 
86
89
  Admin operations — unfiltered reads, cross-agent writes. Every call site is greppable via `git grep "flair.admin"`.
87
90
 
91
+ **The handle is cached** — `flair.admin === flair.admin` is `true`. Access it once and reuse the reference, or access it inline; either is fine.
92
+
88
93
  | Method | Description |
89
94
  |---|---|
90
95
  | `flair.admin.registerAgent(id, opts?)` | Register an agent through the Agent resource (full Principal shape). |
@@ -301,7 +306,7 @@ The facade wraps a lower-level API that is still available for callers who need
301
306
  import { agentContext, adminContext, internalContext, collectionResource } from "@tpsdev-ai/flair/server";
302
307
  ```
303
308
 
304
- This is the same seam Flair's own MCP handler and internal tooling use. You should not need it for ordinary agent operations — the facade covers those. Use the primitives when you are building your own abstraction on top of Flair's resources.
309
+ This is the same seam Flair's own MCP handler and internal tooling use. **You should not need it for ordinary agent operations** — the facade covers those. Reach for the primitives when you are building your own abstraction on top of Flair's resources, or when you need the context helpers (`agentContext`, `adminContext`, `internalContext`) to pass into a resource call directly.
305
310
 
306
311
  ### Resolving a resource
307
312
 
@@ -203,7 +203,7 @@ Eleven tools, kept deliberately small:
203
203
  | Tool | What it does |
204
204
  |---|---|
205
205
  | `memory_search` | Semantic search across your agent's memories |
206
- | `memory_store` | Save a memory with type, durability, tags. Auto-dedups near-duplicates |
206
+ | `memory_store` | Save a memory with type, durability, tags, visibility. Auto-dedups near-duplicates |
207
207
  | `memory_update` | Update an existing memory by ID — overwrite in place, or version it with `preserveHistory` |
208
208
  | `memory_get` | Fetch a specific memory by ID |
209
209
  | `memory_delete` | Remove a memory |
@@ -214,7 +214,9 @@ Eleven tools, kept deliberately small:
214
214
  | `flair_workspace_set` | Set your agent's current workspace state (ref/branch, phase, task) in the Office Space |
215
215
  | `flair_orgevent` | Publish an org-wide coordination event (claim/release/status) to the Office Space |
216
216
 
217
- Writes are scoped per-agent (your `FLAIR_AGENT_ID`) and enforced by Flair's server, not by client convention — you can't write as another agent. Reads are more open by design: any agent on the same Flair instance can read any other agent's non-private memories (open-within-org read; see [SECURITY.md](../SECURITY.md)). Mark a memory `visibility: private` to keep it owner-only.
217
+ Writes are scoped per-agent (your `FLAIR_AGENT_ID`) and enforced by Flair's server, not by client convention — you can't write as another agent. Reads are more open by design: any agent on the same Flair instance can read any other agent's **non-private** memories, with no grant to set up (open-within-org read; see [SECURITY.md](../SECURITY.md)).
218
+
219
+ Which memories are non-private is decided at write time, and the default is not "shared". `memory_store` defaults `durability` to `standard`, and the server derives visibility from durability — `permanent`/`persistent` → `shared`, `standard`/`ephemeral` → `private` — so **a bare `memory_store` call writes an owner-only memory that no other agent can read.** Pass `visibility: "shared"` (or `"private"`, to be explicit) to say what you mean; the tool reports the visibility the write actually landed on so an agent can confirm it rather than assume.
218
220
 
219
221
  ---
220
222
 
@@ -26,7 +26,9 @@ export PATH="$HOME/.npm-global/bin:$PATH" # add this to ~/.zshrc or ~/.bashrc
26
26
  npm install -g @tpsdev-ai/flair
27
27
  ```
28
28
 
29
- One install gives you `flair`, `flair-mcp` and the client library.
29
+ One install gives you one command: `flair`.
30
+
31
+ The stdio adapter your MCP client talks to is a separate package, `@tpsdev-ai/flair-mcp`, and it is deliberately not installed globally — `flair init` wires each client to fetch it on demand with `npx -y @tpsdev-ai/flair-mcp@<version>`, so there is no second global package to keep in step. (The server also has its own `/mcp` endpoint built in, but it is off by default — it registers no route unless `FLAIR_MCP_OAUTH` and a public issuer are set — and no client setup in this guide uses it.) `@tpsdev-ai/flair-client` is a separate package you add to your own project when you want to call Flair from code.
30
32
 
31
33
  ## 2. Bootstrap Flair and register an agent
32
34
 
@@ -110,12 +112,35 @@ flair memory add --agent local "Harper v5 sandbox blocks node:module but process
110
112
  {
111
113
  "id": "local-1785277247486",
112
114
  "written": true,
113
- "deduplicated": false
115
+ "deduplicated": false,
116
+ "visibility": "private"
114
117
  }
115
118
  ```
116
119
 
117
120
  Flair embedded the text locally on write. No network calls.
118
121
 
122
+ ### Who can read it
123
+
124
+ `visibility: private` means **only `local` can read this memory** — no other agent on the instance can search it, fetch it by id, or receive it in a bootstrap.
125
+
126
+ You didn't ask for that, and it isn't a setting you have to remember. Flair derives the default from the memory's **durability**, because how long a memory is meant to last is a good proxy for who it was meant for:
127
+
128
+ | Durability | Default visibility |
129
+ |---|---|
130
+ | `permanent`, `persistent` | `shared` — a fact or decision worth keeping is worth the team being able to find |
131
+ | `standard`, `ephemeral` — including a bare write with no `--durability` | `private` — working context and scratch state belong to the agent that produced them |
132
+
133
+ So sharing is a deliberate act, and it takes one flag:
134
+
135
+ ```bash
136
+ flair memory add --agent local --visibility shared \
137
+ "Release tags are cut from main, never from a release branch"
138
+ ```
139
+
140
+ `--visibility` takes exactly `private` or `shared` — a value it doesn't recognise is an error, not a guess — and overrides the durability rule in both directions. The `visibility` field in the response is the value the memory actually landed on, so read it rather than assuming.
141
+
142
+ Once a memory is `shared`, **every** agent on this instance can read it, with no grant to set up. That is the shipped model: reads open within one instance, closed at the federation edge. Full picture in [SECURITY.md](../SECURITY.md).
143
+
119
144
  ## 5. Find it back by meaning
120
145
 
121
146
  ```bash
@@ -131,7 +156,7 @@ You searched for a concept, not the keywords. The line under each hit is its cre
131
156
 
132
157
  > The percentage is a **rank-fusion score, not a similarity**. It is normalized so the top result is always near 100%. Read it as ordering within these results, never as confidence that the match is good.
133
158
 
134
- Add `--explain` to see the ranking inputs, or `--limit`, `--tag`, `--since 7d` to narrow the search. `flair memory search` runs the same query but always prints raw JSON — use it when piping to a script.
159
+ Add `--explain` to see the ranking inputs per hit — the raw score, the composite score under `--scoring composite`, and the record's durability, age and usage count. When output is JSON (`--json`, or any time stdout is not a terminal) the same breakdown arrives as an `_explain` object on each hit, so scripts get it too. Use `--limit`, `--tag`, `--since 7d` to narrow the search. `flair memory search` runs the same query but always prints raw JSON — use it when piping to a script.
135
160
 
136
161
  ## 6. Give your agent context on boot
137
162
 
@@ -160,7 +185,7 @@ With the MCP server wired up — `flair init` does this automatically for every
160
185
  | You want to... | Go to |
161
186
  |----------------|-------|
162
187
  | Add more agents to the same instance | `flair agent add <id>` |
163
- | Keep a memory owner-only | `flair memory add --visibility private` — reads are otherwise open to every agent on the instance ([auth.md](auth.md)) |
188
+ | Share a memory with your other agents | `flair memory add --visibility shared` — a bare write lands `private`, see [step 4](#who-can-read-it); a shared one is readable by every agent on the instance, no grant needed ([auth.md](auth.md)) |
164
189
  | Import memories from agentic-stack / Mem0 / etc. | [bridges.md](bridges.md) |
165
190
  | Sync memories across machines | [federation.md](federation.md) |
166
191
  | Integrate with OpenClaw, Claude Code, Cursor | [README.md#integration](../README.md#integration) |
package/docs/the-team.md CHANGED
@@ -15,7 +15,7 @@ If you're trying to run your own multi-agent team using Flair as the memory laye
15
15
  | **Pulse** | EA / intel scanning / coordination | OpenClaw | Claude API | cloud VM |
16
16
  | **Nathan** | Founder / product owner / human-in-the-loop | (human) | (human) | wherever |
17
17
 
18
- Every agent has its own Ed25519 identity in Flair. They sign every memory write and every read. **Writes are isolated at the Flair API layer** — Sherlock can't accidentally (or maliciously) write into Pulse's memory, because the signature won't verify for anyone but Pulse. Reads are a different story: within one Flair instance, any verified agent can read any other agent's **non-private** memory — that's the shipped model (open-within-org read, no grant needed), not a gap. An agent keeps something genuinely sensitive owner-only by writing it with `visibility: private`. The hard access boundary is the **federation edge** (a separate Flair instance), not reads within one.
18
+ Every agent has its own Ed25519 identity in Flair. They sign every memory write and every read. **Writes are isolated at the Flair API layer** — Sherlock can't accidentally (or maliciously) write into Pulse's memory, because the signature won't verify for anyone but Pulse. Reads are a different story: within one Flair instance, any verified agent can read any other agent's **non-private** memory — that's the shipped model (open-within-org read, no grant needed), not a gap. Which memories are non-private is decided at write time: visibility defaults from durability, so `permanent`/`persistent` writes land `shared` and `standard`/`ephemeral` writes land `private`. A teammate's scratch context is therefore owner-only until someone shares it deliberately, and an agent keeps something genuinely sensitive owner-only regardless of durability by writing it with `visibility: private`. The hard access boundary is the **federation edge** (a separate Flair instance), not reads within one.
19
19
 
20
20
  ## How memory flows
21
21
 
@@ -41,10 +41,14 @@ Every agent has its own Ed25519 identity in Flair. They sign every memory write
41
41
 
42
42
  (every agent can read every other
43
43
  agent's non-private memories —
44
- `visibility: private` stays owner-only)
44
+ permanent/persistent land shared,
45
+ standard/ephemeral land private,
46
+ and private stays owner-only)
45
47
  ```
46
48
 
47
- No agent can write into another agent's memory — that's enforced server-side by signature verification, no exceptions. Reads are intentionally open within the org: when Flint commits a piece of strategy, any agent can find it on `memory_search` unless Flint marked it `private`. **By design** — the goal is relevance and findability across the team, not secrecy between roles. An agent that genuinely needs something to stay owner-only (a draft not ready for the team, a sensitive finding pre-disclosure) marks it `visibility: private`; everything else is fair game for any teammate to search.
49
+ No agent can write into another agent's memory — that's enforced server-side by signature verification, no exceptions. Reads are intentionally open within the org: when Flint commits a piece of strategy as a `permanent` or `persistent` memory, it lands `shared` and any agent can find it on `memory_search`. **By design** — the goal is relevance and findability across the team, not secrecy between roles.
50
+
51
+ The reverse also holds, and it is the part worth internalising: a `standard` or `ephemeral` write lands `private`, so an agent's day-to-day working context is *not* team-searchable by default. Commit something at `permanent`/`persistent` durability, or pass `visibility: shared`, when you mean the team to find it. An agent that needs something owner-only whatever its durability (a draft not ready for the team, a sensitive finding pre-disclosure) marks it `visibility: private` explicitly.
48
52
 
49
53
  When agents need to *coordinate* — a direct, targeted handoff rather than ambient searchable memory — they pass **explicit messages** through TPS mail (a separate signed delivery channel; see [tpsdev-ai/cli](https://github.com/tpsdev-ai/cli)). That's a different concern from memory visibility: TPS mail is for "I need you, specifically, to see this now"; Flair memory is the shared, searchable record everyone (except where `private`) can draw on later.
50
54
 
@@ -106,7 +110,7 @@ The MCP server (`@tpsdev-ai/flair-mcp`) is what makes this orchestrator-agnostic
106
110
 
107
111
  ## What we deliberately don't do
108
112
 
109
- - **No shared write identity.** Every memory is written and owned by exactly one agent's Ed25519 key — there's no merged "team" identity that can write on another agent's behalf. Reads are a separate story: within the org, any agent can search any other's non-private memory by default (see [SECURITY.md](../SECURITY.md)) — that's intentional, not a leak. TPS mail is still how agents route a message to a *specific* teammate; it's for targeted delivery, not for gating ambient visibility.
113
+ - **No shared write identity.** Every memory is written and owned by exactly one agent's Ed25519 key — there's no merged "team" identity that can write on another agent's behalf. Reads are a separate story: within the org, any agent can search any other's non-private memory by default (see [SECURITY.md](../SECURITY.md)) — that's intentional, not a leak. "Non-private" is set at write time from durability, not by a per-pair grant. TPS mail is still how agents route a message to a *specific* teammate; it's for targeted delivery, not for gating ambient visibility.
110
114
  - **No silent LLM-driven memory extraction.** Each agent decides what it remembers. No background "summarize and persist" on every turn — that's how memory drifts away from intent.
111
115
  - **No multiple agents on one identity.** "Anvil" and "Anvil-2" would be two separate agentIds with two separate keys. Same workload, different identities, separately-owned memories.
112
116
  - **No replay-safe-but-otherwise-unsigned reads.** Every Flair request is Ed25519-signed and verified, including reads. Even on a private network we don't trust the network.
@@ -150,7 +150,7 @@ flair agent list
150
150
  **Possible causes:**
151
151
  1. **Hash-fallback embeddings:** Check `flair status` — if embeddings are in hash mode, semantic search won't work properly. Fix with `flair reembed`.
152
152
  2. **Content safety flags:** The memory might have been flagged. Search for it directly: `flair memory list --agent <id>`.
153
- 3. **`visibility: private`:** Reads are open within the org by default any agent can find any other agent's non-private memories. If the memory was written with `visibility: private`, only its author can find it; search as that agent instead.
153
+ 3. **`visibility: private`:** Only its author can find a `private` memory. This is the most common cause when one agent wrote the memory and another is searching for it, because **`private` is what a bare write lands on**: visibility defaults from durability (`permanent`/`persistent` → `shared`, `standard`/`ephemeral` → `private`), and a write with no `--durability` is `standard`. Check what the memory landed on with `flair memory list --agent <author> --json` (the table view doesn't show visibility, the JSON records do), then either search as its author, or rewrite it with `--visibility shared` so every agent on the instance can find it (no grant needed non-private reads are open within the instance).
154
154
  4. **Dedup threshold:** If the content is very similar to an existing memory, it may have been deduplicated. Check with `flair memory list`.
155
155
 
156
156
  ### High memory usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",