@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 +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +225 -77
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-tools.js +40 -0
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/mcp-clients.md +4 -2
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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
|
-
|
|
61
|
+
Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance needs no key at all — see [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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10222
|
-
|
|
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
|
-
|
|
10226
|
-
|
|
10227
|
-
|
|
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
|
-
|
|
10238
|
-
|
|
10239
|
-
const
|
|
10240
|
-
|
|
10241
|
-
|
|
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.
|
|
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
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
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
|
-
|
|
12723
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
13237
|
-
if (
|
|
13238
|
-
|
|
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
|
-
|
|
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().
|
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/docs/mcp-clients.md
CHANGED
|
@@ -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)).
|
|
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
|
|
package/docs/quickstart.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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`:**
|
|
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.
|
|
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",
|