@tpsdev-ai/flair 0.8.0 → 0.8.2

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
@@ -4,23 +4,76 @@
4
4
  [![Docker Test](https://github.com/tpsdev-ai/flair/actions/workflows/docker-test.yml/badge.svg)](https://github.com/tpsdev-ai/flair/actions/workflows/docker-test.yml)
5
5
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
6
 
7
- **Identity, memory, and soul for AI agents. Runs standalone or as part of a [TPS](https://tps.dev) office.**
7
+ > **The memory system that gives agents a durable identity, not just durable facts. Federated. Crypto-isolated. Yours.**
8
8
 
9
- Agents forget everything between sessions. Flair gives them a persistent sense of self who they are, what they know, how they think backed by cryptographic identity and semantic search.
9
+ Most agent memory systems store *facts*. Flair stores facts AND the agent's identityits character, its values, its accumulated way of working. Same agent, different orchestrator: memory follows. Same network, different machine: memory federates. Same instance, different agents: memory is crypto-isolated end-to-end.
10
10
 
11
- Built on [Harper](https://harper.fast). Single process. No sidecars. Zero external API calls for embeddings.
11
+ ```
12
+ ┌──────────────────────────────────────────────────────────────────┐
13
+ │ same agent, same memory, every harness │
14
+ │ │
15
+ │ Claude Code ─┐ │
16
+ │ Cursor ─┤ │
17
+ │ Codex CLI ─┼─[ flair-mcp ]─┐ │
18
+ │ Gemini CLI ─┤ │ │
19
+ │ Continue.dev ─┤ │ ┌──────────────────────┐ │
20
+ │ Goose ─┘ ├─▶ │ Flair (rockit) │ │
21
+ │ LangGraph ─[ langgraph-flair ]──│ Ed25519 / HNSW / │ │
22
+ │ OpenClaw ─[ openclaw-flair ]──│ Soul + Memory │ │
23
+ │ n8n ─[ n8n-nodes-flair ]──└──────────┬───────────┘ │
24
+ │ Hermes ─[ hermes-flair ]─┘ │ federation │
25
+ │ Pi agent ─[ pi-flair ]─┘ │ (hub/spoke) │
26
+ │ ▼ │
27
+ │ ┌──────────────────────┐ │
28
+ │ │ Flair (Fabric hub) │ │
29
+ │ └──────────────────────┘ │
30
+ └──────────────────────────────────────────────────────────────────┘
31
+ ```
32
+
33
+ 11 harness surfaces today. Pick whichever you're shipping in; the memory layer doesn't care. **[See the full integrations catalog →](docs/integrations.md)**
34
+
35
+ ## What it looks like
36
+
37
+ ![Flair: write a memory, find it by meaning, list of supported harnesses](docs/assets/flair-demo.gif)
38
+
39
+ `flair memory add` writes a memory; `flair search` finds it by meaning, not keywords. The same memory is then visible to every harness in the catalog above.
40
+
41
+ ## How Flair compares
12
42
 
13
- ## Why
43
+ | | Flair | Mem0 | Honcho | Letta (MemGPT) | Built-ins (OAI/Anthropic/Google) |
44
+ |---|---|---|---|---|---|
45
+ | **License** | Apache 2.0 | Apache 2.0 | AGPL-3.0 | Apache 2.0 | proprietary |
46
+ | **Self-host** | yes | yes (Docker) | yes (Docker) | yes | no |
47
+ | **Managed cloud also offered** | no | yes (app.mem0.ai) | yes (app.honcho.dev) | no | only mode |
48
+ | **Identity model** | **Ed25519 per agent (crypto-pinned)** | tenant-isolation | per-user soft tenant | runtime-bound | account-scoped |
49
+ | **Federation (peer-to-peer)** | **yes — hub/spoke validated** | no | no | no | no |
50
+ | **Cross-orchestrator** | **11+ harnesses, same memory** | several | several | runtime-bound | vendor-locked |
51
+ | **Semantic search** | in-process (nomic-embed, no API calls) | yes | yes | yes | varies |
52
+ | **Soul / persistent character** | **first-class** | optional | persona-shaped | optional | no |
53
+
54
+ Mem0, Honcho, and Letta are all open-source and self-hostable — credit where it's due. That's why we don't lead with "open source" as the differentiator. The cells in **bold** are the ones that, in our reading, no other system can claim cleanly today: crypto-pinned per-agent identity, peer-to-peer federation, the breadth of cross-orchestrator integrations, and soul as a first-class primitive.
55
+
56
+ The honest gaps:
57
+
58
+ - Mem0's **cloud sync UX** is more polished if you're OK with their hosting.
59
+ - Honcho's **persona model** is more developed if rich personality modeling is your priority.
60
+ - Letta's **runtime integration** is tighter if you're building on their agent loop.
61
+
62
+ If you need any of those specifically, use them. If you need crypto-pinned identity + federation + cross-orchestrator breadth + soul-as-a-feature — that's the gap Flair fills.
63
+
64
+ ## Why this exists
14
65
 
15
66
  Every agent framework gives you chat history. None of them give you *identity*.
16
67
 
17
68
  An agent that can't remember what it learned yesterday, can't prove who it is to another agent, and loses its personality on restart isn't really an agent. It's a stateless function with a system prompt.
18
69
 
19
- Flair fixes that:
70
+ Flair fixes that with three primitives:
71
+
72
+ - **Identity** — Ed25519 key pairs. Agents sign every request. No passwords, no API keys, no shared secrets. Cross-agent reads are refused at the server, not by client convention.
73
+ - **Memory** — Persistent knowledge with semantic search (in-process [nomic-embed-text](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF), 768-dim, no API calls). Tiered durability (`permanent` / `persistent` / `standard` / `ephemeral`). Temporal validity. Decay-and-retrieval-aware composite scoring.
74
+ - **Soul** — Personality, values, procedures. The stuff that makes an agent *that agent*. Re-injected every turn via the context-engine plugin so it doesn't drift across long sessions.
20
75
 
21
- - **Identity** Ed25519 key pairs. Agents sign every request. No passwords, no API keys, no shared secrets.
22
- - **Memory** — Persistent knowledge with semantic search. Write a lesson learned today, find it six months from now by meaning, not keywords.
23
- - **Soul** — Personality, values, procedures. The stuff that makes an agent *that agent*, not just another LLM wrapper.
76
+ Built on [Harper](https://harper.fast). Single process. No sidecars. Zero external API calls for embeddings. **[Supply-chain policy](docs/supply-chain-policy.md)** documents the bake-time + dep-pinning we run to keep this honest.
24
77
 
25
78
  ## How It Works
26
79
 
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Built-in bridge: chatgpt
3
+ *
4
+ * Imports ChatGPT memories into Flair.
5
+ *
6
+ * Verified workflow (authoritative source — Anthropic's
7
+ * claude.com/import-memory page describing the ChatGPT→Claude migration,
8
+ * verified 2026-05-10): the canonical user path for moving ChatGPT
9
+ * memories is the text-paste workflow. Anthropic publishes the extraction
10
+ * prompt and frames the migration as copy-paste, not file-import:
11
+ *
12
+ * 1. Run this prompt in ChatGPT:
13
+ * "Please share all the memories you have stored about me. List
14
+ * each memory as a separate bullet point, using plain language."
15
+ * 2. Copy the resulting bulleted text block
16
+ * 3. Paste into a .txt or .md file
17
+ * 4. Run: flair bridge import chatgpt --source memories.txt --agent <id>
18
+ *
19
+ * Less-certain (OpenAI's data-export help article was not directly
20
+ * fetchable during verification; third-party guides disagree on whether
21
+ * ChatGPT memories appear in the OpenAI data-export ZIP at all). The
22
+ * bridge does NOT auto-discover anything inside a directory source for
23
+ * this reason — no Anthropic-published source identifies a known memories
24
+ * filename inside the ChatGPT export, and silently picking up a wrong
25
+ * file would be worse than asking the user to point at the file
26
+ * explicitly. Directory inputs throw with workflow guidance pointing
27
+ * back at the extraction prompt.
28
+ *
29
+ * Primary input: plain text file. One memory per line, optionally bullet-
30
+ * prefixed (-, *, •, 1., 1)). Empty lines are skipped.
31
+ *
32
+ * Fallback input: JSON file containing { memories: [...] } or a top-level
33
+ * array. Supports third-party tools that scrape memory UI into JSON.
34
+ *
35
+ * Round-trip: one-way. ChatGPT's memory store is closed.
36
+ *
37
+ * Usage:
38
+ * flair bridge import chatgpt --source memories.txt --agent <id>
39
+ * flair bridge import chatgpt --source memories.json --agent <id> (third-party JSON)
40
+ */
41
+ import { promises as fsp } from "node:fs";
42
+ import { BridgeRuntimeError } from "../types.js";
43
+ // Strip common bullet/numbered-list prefixes from a line.
44
+ // Examples: "- foo" → "foo"; "* foo" → "foo"; "• foo" → "foo";
45
+ // "1. foo" → "foo"; "1) foo" → "foo"; " - foo" → "foo".
46
+ function stripBulletPrefix(line) {
47
+ const trimmed = line.trim();
48
+ // Markdown bullets + unicode bullet
49
+ const bulletMatch = trimmed.match(/^([-*•])\s+(.*)$/);
50
+ if (bulletMatch)
51
+ return bulletMatch[2];
52
+ // Numbered list: "1. ", "1) ", "12. ", etc.
53
+ const numberedMatch = trimmed.match(/^\d+[.)]\s+(.*)$/);
54
+ if (numberedMatch)
55
+ return numberedMatch[1];
56
+ return trimmed;
57
+ }
58
+ function parsePlainText(raw) {
59
+ return raw
60
+ .split(/\r?\n/)
61
+ .map(stripBulletPrefix)
62
+ .filter((line) => line.length > 0);
63
+ }
64
+ async function* importChatGPT(opts, ctx) {
65
+ const { isAbsolute, resolve } = await import("node:path");
66
+ const sourceArg = typeof opts.source === "string" ? opts.source : "";
67
+ if (!sourceArg) {
68
+ throw new BridgeRuntimeError({
69
+ bridge: "chatgpt",
70
+ op: "import",
71
+ path: "(unset)",
72
+ field: "source",
73
+ expected: "path to a .txt/.md/.json file containing your ChatGPT memories",
74
+ got: "missing",
75
+ hint: "pass --source <path>; example: flair bridge import chatgpt --source memories.txt --agent <id>",
76
+ });
77
+ }
78
+ const filePath = isAbsolute(sourceArg) ? sourceArg : resolve(process.cwd(), sourceArg);
79
+ // Stat first — directory inputs are not supported for chatgpt because
80
+ // OpenAI's data export contains no memories file. Surface that explicitly
81
+ // rather than silently failing on a missing memory.json.
82
+ let isDir = false;
83
+ try {
84
+ const stat = await fsp.stat(filePath);
85
+ isDir = stat.isDirectory();
86
+ }
87
+ catch (err) {
88
+ throw new BridgeRuntimeError({
89
+ bridge: "chatgpt",
90
+ op: "import",
91
+ path: filePath,
92
+ field: "source",
93
+ expected: "readable .txt/.md/.json file",
94
+ got: err?.code ?? "ENOENT",
95
+ hint: `could not resolve source: ${err?.message ?? err}`,
96
+ });
97
+ }
98
+ if (isDir) {
99
+ throw new BridgeRuntimeError({
100
+ bridge: "chatgpt",
101
+ op: "import",
102
+ path: filePath,
103
+ field: "source",
104
+ expected: "a file (not a directory)",
105
+ got: "directory",
106
+ hint: "Point --source at a specific file, not a directory. The canonical workflow (per Anthropic's published ChatGPT→Claude migration guide) is the text-paste path: run the extraction prompt in ChatGPT (\"List all memories you have about me as bullet points\"), paste the result into a .txt file, and pass that file as --source.",
107
+ });
108
+ }
109
+ ctx.log.info("reading ChatGPT memory dump", { path: filePath });
110
+ let raw;
111
+ try {
112
+ raw = await fsp.readFile(filePath, "utf-8");
113
+ }
114
+ catch (err) {
115
+ throw new BridgeRuntimeError({
116
+ bridge: "chatgpt",
117
+ op: "import",
118
+ path: filePath,
119
+ field: "source.path",
120
+ expected: "readable file",
121
+ got: err?.code ?? "ENOENT",
122
+ hint: `could not read source file: ${err?.message ?? err}`,
123
+ });
124
+ }
125
+ // Detect input shape. Try JSON parse first; if it fails AND the file
126
+ // doesn't have a .json extension, fall back to plain-text line parse.
127
+ // This makes JSON the sticky path for users who clearly meant JSON
128
+ // (so we don't silently treat broken JSON as 1-line "memories"), and
129
+ // text the natural path for the migration-prompt workflow.
130
+ const isJsonExt = /\.(json)$/i.test(filePath);
131
+ let memories = [];
132
+ let parsed = null;
133
+ let jsonParseErr = null;
134
+ try {
135
+ parsed = JSON.parse(raw);
136
+ }
137
+ catch (err) {
138
+ jsonParseErr = err;
139
+ }
140
+ if (parsed !== null) {
141
+ // Parsed as JSON — accept { memories: [...] } or top-level array.
142
+ if (Array.isArray(parsed)) {
143
+ memories = parsed;
144
+ }
145
+ else if (Array.isArray(parsed?.memories)) {
146
+ memories = parsed.memories;
147
+ }
148
+ else if (Array.isArray(parsed?.user_memory)) {
149
+ memories = parsed.user_memory; // older third-party shape
150
+ }
151
+ else {
152
+ throw new BridgeRuntimeError({
153
+ bridge: "chatgpt",
154
+ op: "import",
155
+ path: filePath,
156
+ field: "(document)",
157
+ expected: "{ memories: [...] } or top-level array",
158
+ got: typeof parsed,
159
+ hint: `JSON parsed but shape is unexpected — keys at root: ${Object.keys(parsed ?? {}).join(", ") || "none"}. If you meant to pass a plain-text bullet list, save it as .txt instead of .json.`,
160
+ });
161
+ }
162
+ }
163
+ else if (isJsonExt) {
164
+ // Extension says JSON but parse failed — surface that, don't silently fall back.
165
+ throw new BridgeRuntimeError({
166
+ bridge: "chatgpt",
167
+ op: "import",
168
+ path: filePath,
169
+ field: "(document)",
170
+ expected: "valid JSON (file has .json extension)",
171
+ got: "parse error",
172
+ hint: `JSON parse failed: ${jsonParseErr?.message ?? jsonParseErr}. If you meant plain text, rename to .txt.`,
173
+ });
174
+ }
175
+ else {
176
+ // Plain-text path.
177
+ memories = parsePlainText(raw);
178
+ }
179
+ ctx.log.info("found memories", { count: memories.length });
180
+ let kept = 0;
181
+ let skipped = 0;
182
+ for (let i = 0; i < memories.length; i++) {
183
+ const m = memories[i];
184
+ // Lenient extract — try several common shapes.
185
+ const content = typeof m === "string" ? m
186
+ : typeof m?.content === "string" ? m.content
187
+ : typeof m?.text === "string" ? m.text
188
+ : typeof m?.body === "string" ? m.body
189
+ : null;
190
+ if (!content || content.trim() === "") {
191
+ skipped++;
192
+ continue;
193
+ }
194
+ kept++;
195
+ const objM = typeof m === "object" && m !== null ? m : null;
196
+ yield {
197
+ foreignId: typeof objM?.id === "string" ? `chatgpt:${objM.id}` : `chatgpt:idx-${i}`,
198
+ content: content.trim(),
199
+ createdAt: typeof objM?.created_at === "string" ? objM.created_at : undefined,
200
+ tags: ["source:chatgpt", "import:chatgpt"],
201
+ durability: "persistent",
202
+ };
203
+ }
204
+ ctx.log.info("import complete", { kept, skipped });
205
+ }
206
+ export const chatgptMemoryBridge = {
207
+ name: "chatgpt",
208
+ version: 1,
209
+ kind: "file",
210
+ description: "Import ChatGPT memories (paste-the-prompt-output workflow) into Flair",
211
+ options: {
212
+ source: {
213
+ description: "Path to a .txt/.md file with one memory per line/bullet, OR a .json file with { memories: [...] }.",
214
+ required: true,
215
+ },
216
+ },
217
+ import: importChatGPT,
218
+ // No export — ChatGPT's memory store is closed; round-trip would have
219
+ // nowhere useful to write back to.
220
+ };
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Built-in bridge: claude-project
3
+ *
4
+ * Imports Claude memories into Flair.
5
+ *
6
+ * Verified workflow (authoritative source — claude.com/import-memory,
7
+ * verified 2026-05-10): Anthropic's published memory-import flow is
8
+ * copy-paste only. The user runs an extraction prompt in Claude, copies
9
+ * the result, and pastes it into Claude's memory settings (or, in our
10
+ * case, into a local file we then import).
11
+ *
12
+ * Less-certain (third-party sources disagree, Anthropic's data-export
13
+ * help article does not publish the file list): whether the standard
14
+ * Claude data-export ZIP (Settings → Privacy → Export data) contains a
15
+ * `memories.json` file. The bridge hedges by accepting either path:
16
+ * - Primary: the copy-paste workflow → user-staged .txt/.md/.json
17
+ * - Fallback: an extracted data-export directory whose memories file
18
+ * is named `memories.json` (auto-discovered alongside the user-staged
19
+ * names — see `findMemoryFileInDir` below)
20
+ *
21
+ * Primary input: plain text (.txt or .md) — bullet-prefixed or raw lines.
22
+ * Fallback input: JSON containing `{ memories: [...] }` or top-level array.
23
+ * Optional: a `project.json` file alongside supplying `{ name: "..." }`
24
+ * to label the imported memories with a project subject + foreignId.
25
+ *
26
+ * Round-trip: one-way. Claude's memory store is closed.
27
+ *
28
+ * Usage:
29
+ * flair bridge import claude-project --source memory.txt --agent <id>
30
+ * flair bridge import claude-project --source memory.json --agent <id>
31
+ * flair bridge import claude-project --source ./claude-export --agent <id>
32
+ * (where ./claude-export contains memory.{txt,md,json} or memories.json,
33
+ * plus optional project.json)
34
+ */
35
+ import { promises as fsp } from "node:fs";
36
+ import { join, dirname } from "node:path";
37
+ import { BridgeRuntimeError } from "../types.js";
38
+ function stripBulletPrefix(line) {
39
+ const trimmed = line.trim();
40
+ const bulletMatch = trimmed.match(/^([-*•])\s+(.*)$/);
41
+ if (bulletMatch)
42
+ return bulletMatch[2];
43
+ const numberedMatch = trimmed.match(/^\d+[.)]\s+(.*)$/);
44
+ if (numberedMatch)
45
+ return numberedMatch[1];
46
+ return trimmed;
47
+ }
48
+ function parsePlainText(raw) {
49
+ return raw
50
+ .split(/\r?\n/)
51
+ .map(stripBulletPrefix)
52
+ .filter((line) => line.length > 0);
53
+ }
54
+ // Best-effort discovery of the "memory file" inside a directory source.
55
+ // Order:
56
+ // 1. memory.txt — user-staged from the copy-paste workflow (most common)
57
+ // 2. memory.md — user-staged markdown variant
58
+ // 3. memory.json — user-staged JSON wrapper
59
+ // 4. memories.json — possible filename inside an Anthropic data-export
60
+ // ZIP. Anthropic's data-export help article doesn't
61
+ // publish the ZIP file list and third-party tools
62
+ // disagree on whether this file is present; including
63
+ // it here is a cheap hedge so the bridge handles either
64
+ // reality without a follow-up patch.
65
+ // Returns the first that exists, or null if none are present.
66
+ async function findMemoryFileInDir(dir) {
67
+ for (const name of ["memory.txt", "memory.md", "memory.json", "memories.json"]) {
68
+ const candidate = join(dir, name);
69
+ try {
70
+ const stat = await fsp.stat(candidate);
71
+ if (stat.isFile())
72
+ return candidate;
73
+ }
74
+ catch {
75
+ // not present, continue
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ async function* importClaudeProject(opts, ctx) {
81
+ const { isAbsolute, resolve } = await import("node:path");
82
+ const sourceArg = typeof opts.source === "string" ? opts.source : "";
83
+ if (!sourceArg) {
84
+ throw new BridgeRuntimeError({
85
+ bridge: "claude-project",
86
+ op: "import",
87
+ path: "(unset)",
88
+ field: "source",
89
+ expected: "path to a .txt/.md/.json file (or directory containing one)",
90
+ got: "missing",
91
+ hint: "pass --source <path>; example: flair bridge import claude-project --source memory.txt --agent <id>",
92
+ });
93
+ }
94
+ const sourceAbs = isAbsolute(sourceArg) ? sourceArg : resolve(process.cwd(), sourceArg);
95
+ let filePath;
96
+ let sourceDir;
97
+ try {
98
+ const stat = await fsp.stat(sourceAbs);
99
+ if (stat.isDirectory()) {
100
+ sourceDir = sourceAbs;
101
+ const found = await findMemoryFileInDir(sourceAbs);
102
+ if (!found) {
103
+ throw new BridgeRuntimeError({
104
+ bridge: "claude-project",
105
+ op: "import",
106
+ path: sourceAbs,
107
+ field: "source",
108
+ expected: "directory containing memory.txt, memory.md, or memory.json",
109
+ got: "directory with no memory file",
110
+ hint: "Claude's memory export is UI-only — copy your memory from Settings → Capabilities into memory.txt inside this directory, or pass the file path directly as --source.",
111
+ });
112
+ }
113
+ filePath = found;
114
+ }
115
+ else {
116
+ sourceDir = dirname(sourceAbs);
117
+ filePath = sourceAbs;
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (err instanceof BridgeRuntimeError)
122
+ throw err;
123
+ throw new BridgeRuntimeError({
124
+ bridge: "claude-project",
125
+ op: "import",
126
+ path: sourceAbs,
127
+ field: "source",
128
+ expected: "readable file or directory",
129
+ got: err?.code ?? "ENOENT",
130
+ hint: `could not resolve source: ${err?.message ?? err}`,
131
+ });
132
+ }
133
+ if (filePath.endsWith(".zip")) {
134
+ throw new BridgeRuntimeError({
135
+ bridge: "claude-project",
136
+ op: "import",
137
+ path: filePath,
138
+ field: "source",
139
+ expected: "extracted file or directory",
140
+ got: "zip archive",
141
+ hint: `extract the .zip first, then point --source at the extracted directory or file:\n unzip ${filePath} -d ./claude-export && flair bridge import claude-project --source ./claude-export --agent <id>`,
142
+ });
143
+ }
144
+ ctx.log.info("reading Claude memory dump", { path: filePath });
145
+ let raw;
146
+ try {
147
+ raw = await fsp.readFile(filePath, "utf-8");
148
+ }
149
+ catch (err) {
150
+ throw new BridgeRuntimeError({
151
+ bridge: "claude-project",
152
+ op: "import",
153
+ path: filePath,
154
+ field: "source.path",
155
+ expected: "readable file",
156
+ got: err?.code ?? "ENOENT",
157
+ hint: `could not read source file: ${err?.message ?? err}`,
158
+ });
159
+ }
160
+ // Detect input shape. JSON path is sticky for .json extension; text path
161
+ // for .txt/.md. For files without a recognized extension, try JSON first
162
+ // and fall back to text on parse failure.
163
+ const isJsonExt = /\.(json)$/i.test(filePath);
164
+ let memories = [];
165
+ let parsed = null;
166
+ let jsonParseErr = null;
167
+ if (isJsonExt) {
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ }
171
+ catch (err) {
172
+ jsonParseErr = err;
173
+ }
174
+ }
175
+ else {
176
+ // For non-.json extensions, attempt JSON only as a fallback in case
177
+ // the user named a JSON file with a wrong extension. Otherwise treat
178
+ // as plain text.
179
+ try {
180
+ const candidate = JSON.parse(raw);
181
+ if (Array.isArray(candidate) || (candidate && typeof candidate === "object" && (Array.isArray(candidate.memories) || Array.isArray(candidate.user_memory)))) {
182
+ parsed = candidate;
183
+ }
184
+ }
185
+ catch {
186
+ // not JSON — fall through to plain-text path
187
+ }
188
+ }
189
+ if (parsed !== null) {
190
+ if (Array.isArray(parsed)) {
191
+ memories = parsed;
192
+ }
193
+ else if (Array.isArray(parsed?.memories)) {
194
+ memories = parsed.memories;
195
+ }
196
+ else {
197
+ throw new BridgeRuntimeError({
198
+ bridge: "claude-project",
199
+ op: "import",
200
+ path: filePath,
201
+ field: "(document)",
202
+ expected: "{ memories: [...] } or top-level array",
203
+ got: typeof parsed,
204
+ hint: `JSON parsed but shape is unexpected — keys at root: ${Object.keys(parsed ?? {}).join(", ") || "none"}. If you meant plain text, save as .txt instead of .json.`,
205
+ });
206
+ }
207
+ }
208
+ else if (isJsonExt) {
209
+ throw new BridgeRuntimeError({
210
+ bridge: "claude-project",
211
+ op: "import",
212
+ path: filePath,
213
+ field: "(document)",
214
+ expected: "valid JSON (file has .json extension)",
215
+ got: "parse error",
216
+ hint: `JSON parse failed: ${jsonParseErr?.message ?? jsonParseErr}. If you meant plain text, rename to .txt.`,
217
+ });
218
+ }
219
+ else {
220
+ // Plain-text path.
221
+ memories = parsePlainText(raw);
222
+ }
223
+ ctx.log.info("found memories", { count: memories.length });
224
+ // Load project.json (optional) to derive subject and project name for foreignId.
225
+ let projectName = "unknown";
226
+ try {
227
+ const projectRaw = await fsp.readFile(join(sourceDir, "project.json"), "utf-8");
228
+ const project = JSON.parse(projectRaw);
229
+ projectName = project.name || "unknown";
230
+ }
231
+ catch {
232
+ ctx.log.debug("no project.json found, using default project name", { dir: sourceDir });
233
+ }
234
+ const subject = projectName !== "unknown" ? projectName : undefined;
235
+ let kept = 0;
236
+ let skipped = 0;
237
+ for (let i = 0; i < memories.length; i++) {
238
+ const m = memories[i];
239
+ const content = typeof m === "string" ? m
240
+ : typeof m?.content === "string" ? m.content
241
+ : typeof m?.text === "string" ? m.text
242
+ : null;
243
+ if (!content || content.trim() === "") {
244
+ skipped++;
245
+ continue;
246
+ }
247
+ kept++;
248
+ const objM = typeof m === "object" && m !== null ? m : null;
249
+ yield {
250
+ foreignId: typeof objM?.id === "string"
251
+ ? `claude-project:${projectName}:${objM.id}`
252
+ : `claude-project:${projectName}:idx-${i}`,
253
+ content: content.trim(),
254
+ subject,
255
+ createdAt: typeof objM?.created_at === "string" ? objM.created_at : undefined,
256
+ tags: ["source:claude-project", "import:claude-project"],
257
+ durability: "persistent",
258
+ };
259
+ }
260
+ ctx.log.info("import complete", { kept, skipped, project: projectName });
261
+ }
262
+ export const claudeProjectMemoryBridge = {
263
+ name: "claude-project",
264
+ version: 1,
265
+ kind: "file",
266
+ description: "Import Claude memory (Settings → Capabilities copy-paste workflow) into Flair",
267
+ options: {
268
+ source: {
269
+ description: "Path to a .txt/.md file with one memory per line/bullet, OR a .json file with { memories: [...] }, OR a directory containing memory.{txt,md,json} (and optional project.json).",
270
+ required: true,
271
+ },
272
+ },
273
+ import: importClaudeProject,
274
+ // No export — Claude's memory store is closed.
275
+ };
@@ -13,7 +13,10 @@
13
13
  * Then register it here. See specs/FLAIR-BRIDGES.md §2 for shapes A/B.
14
14
  */
15
15
  import { agenticStackDescriptor } from "./agentic-stack.js";
16
+ import { claudeProjectMemoryBridge } from "./claude-project.js";
17
+ import { chatgptMemoryBridge } from "./chatgpt.js";
16
18
  import { markdownMemoryBridge } from "./markdown.js";
19
+ import { mem0MemoryBridge } from "./mem0.js";
17
20
  function builtinDescriptor(d) {
18
21
  return {
19
22
  discovered: {
@@ -43,7 +46,10 @@ function builtinPlugin(p) {
43
46
  /** All bridges shipped inside @tpsdev-ai/flair. Order doesn't matter. */
44
47
  export const BUILTINS = [
45
48
  builtinDescriptor(agenticStackDescriptor),
49
+ builtinPlugin(claudeProjectMemoryBridge),
50
+ builtinPlugin(chatgptMemoryBridge),
46
51
  builtinPlugin(markdownMemoryBridge),
52
+ builtinPlugin(mem0MemoryBridge),
47
53
  ];
48
54
  /** Map name → descriptor/plugin for O(1) runtime lookup. */
49
55
  export const BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.discovered.name, b]));
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Built-in bridge: mem0
3
+ *
4
+ * Imports memories from a Mem0 instance (cloud at api.mem0.ai or self-hosted)
5
+ * into Flair. Pulls every memory for a given user_id via the Mem0 v1 GET
6
+ * /v1/memories/ endpoint, paginating with page/page_size until exhausted.
7
+ * Each imported row becomes one Flair memory tagged `source:mem0` +
8
+ * `import:mem0`, durability `persistent`.
9
+ *
10
+ * One-way import (we don't sync back). This backs the "switch off SaaS
11
+ * memory in 30 seconds" positioning.
12
+ *
13
+ * API schema (verified against api.mem0.ai/openapi.json on 2026-05-10):
14
+ * GET /v1/memories/?user_id=<id>&page=<n>&page_size=<n>
15
+ * Authorization: Token <api-key>
16
+ *
17
+ * Response shape A (v1, bare array — self-hosted + cloud v1):
18
+ * [ { id, memory, created_at, ... }, ... ]
19
+ * Pagination ends when an empty array is returned.
20
+ *
21
+ * Response shape B (v3 / DRF-style paginated envelope):
22
+ * { count, next, previous, results: [...] }
23
+ * Pagination ends when `next` is null.
24
+ *
25
+ * The bridge auto-detects which shape the server returns and handles both.
26
+ *
27
+ * Why a Shape B (code) bridge and not Shape A (YAML descriptor):
28
+ * - The Mem0 API requires bearer-auth headers and pagination.
29
+ * - The Shape A file-based parser can't drive a REST API.
30
+ * - Error handling needs to distinguish 401/403/404 from network failures
31
+ * from malformed responses — easier to express in TS.
32
+ *
33
+ * Usage:
34
+ * flair bridge import mem0 --user <id> --api-key <key> --agent <flair-id>
35
+ * flair bridge import mem0 --user <id> --base-url https://mem0.example.com --agent <flair-id>
36
+ * MEM0_API_KEY=<key> flair bridge import mem0 --user <id> --agent <flair-id>
37
+ */
38
+ import { BridgeRuntimeError } from "../types.js";
39
+ function isPaginatedEnvelope(body) {
40
+ return !Array.isArray(body) && typeof body === "object" && body !== null && Array.isArray(body.results);
41
+ }
42
+ async function* importMem0(opts, ctx) {
43
+ const userId = typeof opts.user === "string" ? opts.user : "";
44
+ if (!userId) {
45
+ throw new BridgeRuntimeError({
46
+ bridge: "mem0",
47
+ op: "import",
48
+ field: "user",
49
+ expected: "Mem0 user_id string",
50
+ got: "missing",
51
+ hint: "pass --user <id>; example: flair bridge import mem0 --user <id> --api-key <key> --agent <flair-id>",
52
+ });
53
+ }
54
+ const apiKey = typeof opts.apiKey === "string" ? opts.apiKey : "";
55
+ if (!apiKey) {
56
+ throw new BridgeRuntimeError({
57
+ bridge: "mem0",
58
+ op: "import",
59
+ field: "apiKey",
60
+ expected: "Mem0 API token (cloud or self-hosted)",
61
+ got: "missing",
62
+ hint: "pass --api-key <token> or set MEM0_API_KEY in the environment",
63
+ });
64
+ }
65
+ const baseUrl = typeof opts.baseUrl === "string" && opts.baseUrl.length > 0
66
+ ? opts.baseUrl.replace(/\/+$/, "")
67
+ : "https://api.mem0.ai";
68
+ const maxPages = typeof opts.maxPages === "number" ? opts.maxPages : 0;
69
+ const pageSize = 100;
70
+ ctx.log.info("starting mem0 import", {
71
+ user_id: userId,
72
+ base_url: baseUrl,
73
+ });
74
+ let pageCount = 0;
75
+ let totalKept = 0;
76
+ let pageNum = 1;
77
+ let cursor = `${baseUrl}/v1/memories/?user_id=${encodeURIComponent(userId)}&page=${pageNum}&page_size=${pageSize}`;
78
+ while (cursor !== null) {
79
+ if (maxPages > 0 && pageCount >= maxPages) {
80
+ ctx.log.info("stopping at max-pages limit", { pages: pageCount, kept: totalKept });
81
+ break;
82
+ }
83
+ ctx.log.debug("fetching page", { url: cursor, page: pageCount + 1 });
84
+ let res;
85
+ try {
86
+ res = await ctx.fetch(cursor, {
87
+ headers: {
88
+ Authorization: `Token ${apiKey}`,
89
+ Accept: "application/json",
90
+ },
91
+ });
92
+ }
93
+ catch (err) {
94
+ throw new BridgeRuntimeError({
95
+ bridge: "mem0",
96
+ op: "import",
97
+ path: cursor,
98
+ field: "fetch",
99
+ expected: "HTTP 200",
100
+ got: `network error: ${err?.message ?? err}`,
101
+ hint: "check the base URL is reachable and the network is up",
102
+ });
103
+ }
104
+ if (res.status === 401) {
105
+ throw new BridgeRuntimeError({
106
+ bridge: "mem0",
107
+ op: "import",
108
+ path: cursor,
109
+ field: "apiKey",
110
+ expected: "valid Mem0 API token",
111
+ got: "HTTP 401 Unauthorized",
112
+ hint: "the API key was rejected — check it's valid and scoped to the user_id",
113
+ });
114
+ }
115
+ if (res.status === 403) {
116
+ throw new BridgeRuntimeError({
117
+ bridge: "mem0",
118
+ op: "import",
119
+ path: cursor,
120
+ field: "apiKey",
121
+ expected: "authorized token",
122
+ got: "HTTP 403 Forbidden",
123
+ hint: "the API key doesn't have permission to read memories for this user_id",
124
+ });
125
+ }
126
+ if (res.status === 404) {
127
+ throw new BridgeRuntimeError({
128
+ bridge: "mem0",
129
+ op: "import",
130
+ path: cursor,
131
+ field: "user",
132
+ expected: "existing user_id",
133
+ got: "HTTP 404 Not Found",
134
+ hint: `user_id "${userId}" was not found on this Mem0 instance`,
135
+ });
136
+ }
137
+ if (!res.ok) {
138
+ const body = await res.text().catch(() => "(could not read body)");
139
+ throw new BridgeRuntimeError({
140
+ bridge: "mem0",
141
+ op: "import",
142
+ path: cursor,
143
+ field: "response",
144
+ expected: "HTTP 200",
145
+ got: `HTTP ${res.status} ${res.statusText}`,
146
+ hint: `unexpected response (${res.status}): ${body.slice(0, 200)}`,
147
+ });
148
+ }
149
+ let body;
150
+ try {
151
+ const text = await res.text();
152
+ if (text.trim() === "") {
153
+ throw new BridgeRuntimeError({
154
+ bridge: "mem0",
155
+ op: "import",
156
+ path: cursor,
157
+ field: "(response)",
158
+ expected: "JSON body",
159
+ got: "empty response",
160
+ hint: "the Mem0 API returned an empty body — check the instance is healthy",
161
+ });
162
+ }
163
+ body = JSON.parse(text);
164
+ }
165
+ catch (err) {
166
+ if (err instanceof BridgeRuntimeError)
167
+ throw err;
168
+ throw new BridgeRuntimeError({
169
+ bridge: "mem0",
170
+ op: "import",
171
+ path: cursor,
172
+ field: "(response)",
173
+ expected: "valid JSON: bare array OR { results: [...], next: string|null }",
174
+ got: "parse error",
175
+ hint: `could not parse response: ${err?.message ?? err}`,
176
+ });
177
+ }
178
+ let mems;
179
+ let nextCursor;
180
+ if (Array.isArray(body)) {
181
+ // v1 bare-array shape — page until we get an empty array.
182
+ mems = body;
183
+ nextCursor = mems.length === 0 || mems.length < pageSize
184
+ ? null
185
+ : `${baseUrl}/v1/memories/?user_id=${encodeURIComponent(userId)}&page=${pageNum + 1}&page_size=${pageSize}`;
186
+ pageNum++;
187
+ }
188
+ else if (isPaginatedEnvelope(body)) {
189
+ // v3 / DRF-style envelope — use server-provided next URL.
190
+ mems = body.results;
191
+ nextCursor = typeof body.next === "string" && body.next.length > 0 ? body.next : null;
192
+ }
193
+ else {
194
+ throw new BridgeRuntimeError({
195
+ bridge: "mem0",
196
+ op: "import",
197
+ path: cursor,
198
+ field: "(response)",
199
+ expected: "bare array OR { results: [...], next: string|null }",
200
+ got: typeof body,
201
+ hint: `unexpected response shape — got ${typeof body}, expected an array or paginated envelope`,
202
+ });
203
+ }
204
+ ctx.log.debug("received page", { count: mems.length, next: nextCursor });
205
+ for (const m of mems) {
206
+ const content = typeof m?.memory === "string" ? m.memory : "";
207
+ if (!content || content.trim() === "")
208
+ continue;
209
+ totalKept++;
210
+ yield {
211
+ foreignId: `mem0:${m.id}`,
212
+ content,
213
+ createdAt: typeof m?.created_at === "string" ? m.created_at : undefined,
214
+ tags: ["source:mem0", "import:mem0"],
215
+ durability: "persistent",
216
+ };
217
+ }
218
+ pageCount++;
219
+ cursor = nextCursor;
220
+ }
221
+ ctx.log.info("mem0 import complete", { pages: pageCount, kept: totalKept });
222
+ }
223
+ export const mem0MemoryBridge = {
224
+ name: "mem0",
225
+ version: 1,
226
+ kind: "api",
227
+ description: "Import memories from a Mem0 instance (cloud or self-hosted) into Flair",
228
+ options: {
229
+ user: {
230
+ description: "Mem0 user_id whose memories to import.",
231
+ required: true,
232
+ },
233
+ apiKey: {
234
+ description: "Mem0 API token. Can also be set via MEM0_API_KEY env var.",
235
+ env: "MEM0_API_KEY",
236
+ required: true,
237
+ },
238
+ baseUrl: {
239
+ description: "Base URL of the Mem0 API (default: https://api.mem0.ai). Use your self-hosted URL for on-prem.",
240
+ },
241
+ maxPages: {
242
+ description: "Maximum number of pages to fetch (0 = unlimited, for testing/dry-run).",
243
+ },
244
+ },
245
+ import: importMem0,
246
+ // No export — one-way import; we don't sync back to Mem0.
247
+ };
package/dist/cli.js CHANGED
@@ -5904,6 +5904,147 @@ memory.command("list")
5904
5904
  console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
5905
5905
  }
5906
5906
  });
5907
+ // ─── flair memory hygiene ────────────────────────────────────────────────────
5908
+ // Detect + remove junk memory rows that accumulate over time. Surfaced from
5909
+ // the 2026-05-07 manual cleanup (ops-ucyy): rockit had 627 records, ~250 of
5910
+ // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
5911
+ // test content ("the quick brown fox..." / "Flair 251 test ..."), and
5912
+ // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
5913
+ // this command bundles those patterns + future ones as an operator tool that
5914
+ // dry-runs by default.
5915
+ //
5916
+ // Three pattern categories, each toggle-able:
5917
+ // --pattern compact-id : ids matching /-compact-/ (legacy pipeline output)
5918
+ // --pattern test-content : content matching pangram / known test strings
5919
+ // --pattern tiny : content shorter than 25 chars
5920
+ //
5921
+ // Default is all three, dry-run. Flip --apply to actually delete. Always
5922
+ // requires admin pass to read across agent scopes (uses ops API).
5923
+ //
5924
+ // Federation note: this only deletes on the local instance. ops-esun
5925
+ // (federation distributed-delete via tombstones) is the systemic answer for
5926
+ // fan-out — until that lands, run `flair memory hygiene` on each peer.
5927
+ // Exported for unit testing — keeps the predicate logic separable from
5928
+ // the CLI plumbing, ops-API fetching, and confirmation flow.
5929
+ export const HYGIENE_TEST_CONTENT_PATTERNS = [
5930
+ /quick brown fox/i,
5931
+ /flair\s*251\s*test/i,
5932
+ /^upgrade-smoke-(pre|post)-marker$/i,
5933
+ ];
5934
+ /** Categorize a single memory row against the enabled hygiene patterns.
5935
+ * Returns the list of categories the row matches; empty array means clean.
5936
+ * Pure function — easy to unit test and reason about. */
5937
+ export function categorizeForHygiene(row, opts) {
5938
+ const cats = [];
5939
+ if (opts.enabled.has("compact-id") && typeof row.id === "string" && row.id.includes("-compact-")) {
5940
+ cats.push("compact-id");
5941
+ }
5942
+ if (opts.enabled.has("test-content") && typeof row.content === "string" && HYGIENE_TEST_CONTENT_PATTERNS.some((p) => p.test(row.content))) {
5943
+ cats.push("test-content");
5944
+ }
5945
+ if (opts.enabled.has("tiny") && typeof row.content === "string" && row.content.length < opts.tinyThreshold) {
5946
+ cats.push("tiny");
5947
+ }
5948
+ return cats;
5949
+ }
5950
+ memory.command("hygiene")
5951
+ .description("Detect and (with --apply) remove junk memory rows from the local instance")
5952
+ .option("--apply", "Actually delete the matched rows (default: dry-run)")
5953
+ .option("--pattern <list>", "Comma-separated patterns to match: compact-id,test-content,tiny (default: all)")
5954
+ .option("--tiny-threshold <n>", "Char length below which content is 'tiny'", "25")
5955
+ .option("--port <port>", "Harper HTTP port")
5956
+ .option("--ops-port <port>", "Harper ops API port (default: HTTP - 1)")
5957
+ .action(async (opts) => {
5958
+ const opsPort = resolveOpsPort(opts);
5959
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
5960
+ if (!adminPass) {
5961
+ console.error("❌ Admin password required (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD).");
5962
+ process.exit(1);
5963
+ }
5964
+ const enabled = new Set((opts.pattern ?? "compact-id,test-content,tiny").split(",").map((s) => s.trim()).filter(Boolean));
5965
+ const tinyThreshold = Math.max(0, Number(opts.tinyThreshold) || 25);
5966
+ const apply = !!opts.apply;
5967
+ const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
5968
+ async function ops(body) {
5969
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5970
+ method: "POST",
5971
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
5972
+ body: JSON.stringify(body),
5973
+ signal: AbortSignal.timeout(60_000),
5974
+ });
5975
+ if (!res.ok) {
5976
+ throw new Error(`ops API failed (${res.status}): ${await res.text().catch(() => "")}`);
5977
+ }
5978
+ return res.json();
5979
+ }
5980
+ // Fetch all rows via ops API. Bypasses /SemanticSearch (vector index) the
5981
+ // same way `flair reembed` does, so this command works even when the
5982
+ // cosine path is broken — exactly the conditions hygiene is most needed.
5983
+ console.log("Scanning Memory table...");
5984
+ const raw = await ops({
5985
+ operation: "search_by_conditions",
5986
+ database: "flair",
5987
+ table: "Memory",
5988
+ operator: "and",
5989
+ conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }],
5990
+ get_attributes: ["id", "agentId", "content", "createdAt"],
5991
+ limit: 100000,
5992
+ });
5993
+ const rows = Array.isArray(raw) ? raw : (raw?.results ?? []);
5994
+ console.log(` ${rows.length} total memories scanned.`);
5995
+ // Match each pattern. Counts by category, single id list for the delete.
5996
+ const matched = new Map();
5997
+ const allIds = new Set();
5998
+ const hygieneOpts = { enabled: enabled, tinyThreshold };
5999
+ for (const r of rows) {
6000
+ const categories = categorizeForHygiene(r, hygieneOpts);
6001
+ for (const c of categories) {
6002
+ if (!matched.has(c))
6003
+ matched.set(c, new Set());
6004
+ matched.get(c).add(r.id);
6005
+ allIds.add(r.id);
6006
+ }
6007
+ }
6008
+ console.log("");
6009
+ console.log(`Match summary (${apply ? "APPLY" : "dry-run"}):`);
6010
+ const allCategories = ["compact-id", "test-content", "tiny"];
6011
+ for (const c of allCategories) {
6012
+ const n = matched.get(c)?.size ?? 0;
6013
+ const enabledMark = enabled.has(c) ? "✓" : "·";
6014
+ console.log(` ${enabledMark} ${c.padEnd(13)} ${n.toString().padStart(5)} rows`);
6015
+ }
6016
+ console.log(` ────────────────────────────`);
6017
+ console.log(` total unique ${allIds.size.toString().padStart(5)} rows`);
6018
+ if (allIds.size === 0) {
6019
+ console.log("\n✅ Nothing to clean.");
6020
+ return;
6021
+ }
6022
+ if (!apply) {
6023
+ console.log("\n(dry-run) — re-run with --apply to delete the matched rows.");
6024
+ return;
6025
+ }
6026
+ // Delete in chunks (Harper accepts batches of hash_values).
6027
+ const ids = Array.from(allIds);
6028
+ const chunkSize = 200;
6029
+ let deleted = 0;
6030
+ for (let i = 0; i < ids.length; i += chunkSize) {
6031
+ const batch = ids.slice(i, i + chunkSize);
6032
+ const result = await ops({
6033
+ operation: "delete",
6034
+ database: "flair",
6035
+ table: "Memory",
6036
+ hash_values: batch,
6037
+ });
6038
+ const m = /(\d+)\s*of\s*\d+\s*records/.exec(result.message ?? "");
6039
+ deleted += m ? Number(m[1]) : batch.length;
6040
+ process.stdout.write(`\r Deleting ${deleted}/${ids.length} (${Math.round((deleted / ids.length) * 100)}%)`);
6041
+ }
6042
+ console.log(`\n\n✅ Deleted ${deleted} rows.`);
6043
+ console.log("");
6044
+ console.log("Note: this is a local-instance delete. Federated peers will keep their copies until");
6045
+ console.log("ops-esun (tombstone-based distributed delete) lands. Until then, run `flair memory");
6046
+ console.log("hygiene --apply` on each peer to fan out.");
6047
+ });
5907
6048
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
5908
6049
  program
5909
6050
  .command("search <query>")
@@ -1,12 +1,37 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  /**
4
- * GET /AdminMemorybrowse and search memories.
4
+ * GET /AdminMemory browse + search memories (list view)
5
+ * GET /AdminMemory?id=<id> per-memory detail view with full provenance pane
6
+ *
7
+ * Provenance pane surfaces:
8
+ * - Identity: id, agentId, subject, contentHash
9
+ * - Tags (with source:* / import:* highlighted as origin chips)
10
+ * - Lineage: derivedFrom, parentId, supersedes (+ reverse: what supersedes this)
11
+ * - Federation: _originatorInstanceId, _syncedFrom, _syncedAt — set by
12
+ * Federation sync write path (resources/Federation.ts) when a record
13
+ * arrives from a peer. Local-origin memories don't have these.
14
+ * - Lifecycle: createdAt, updatedAt, validFrom, validTo, expiresAt,
15
+ * archived/archivedAt/archivedBy, promotionStatus/promotedBy/promotedAt
16
+ * - Usage: retrievalCount, lastRetrieved
17
+ * - Safety: _safetyFlags
18
+ *
19
+ * "memory that follows the agent across orchestrators" — the provenance pane
20
+ * makes that legible: every memory shows where it came from, what it derived
21
+ * from, what it superseded, and which peer it synced from.
5
22
  */
6
23
  export class AdminMemory extends Resource {
7
24
  async get() {
8
25
  const request = this.request;
9
26
  const url = new URL(request?.url ?? "http://localhost", "http://localhost");
27
+ const id = url.searchParams.get("id") ?? "";
28
+ if (id) {
29
+ return this.renderDetail(id);
30
+ }
31
+ return this.renderList(url);
32
+ }
33
+ // ─── List view ─────────────────────────────────────────────────────────────
34
+ async renderList(url) {
10
35
  const query = url.searchParams.get("q") ?? "";
11
36
  const subject = url.searchParams.get("subject") ?? "";
12
37
  const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
@@ -34,11 +59,11 @@ export class AdminMemory extends Resource {
34
59
  memories.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
35
60
  let tableRows = "";
36
61
  if (memories.length === 0) {
37
- tableRows = `<tr><td colspan="5" class="empty">No memories found${query ? ` matching "${query}"` : ""}.</td></tr>`;
62
+ tableRows = `<tr><td colspan="6" class="empty">No memories found${query ? ` matching "${query}"` : ""}.</td></tr>`;
38
63
  }
39
64
  else {
40
65
  for (const m of memories) {
41
- const preview = (m.content || "").slice(0, 120) + ((m.content || "").length > 120 ? "…" : "");
66
+ const preview = (m.content || "").slice(0, 100) + ((m.content || "").length > 100 ? "…" : "");
42
67
  const durability = m.durability ?? "standard";
43
68
  const durBadge = durability === "permanent"
44
69
  ? `<span class="badge badge-green">${durability}</span>`
@@ -48,11 +73,18 @@ export class AdminMemory extends Resource {
48
73
  const subjectStr = m.subject ?? "—";
49
74
  const created = m.createdAt?.slice(0, 10) ?? "—";
50
75
  const validity = m.validTo ? `<small>expired ${m.validTo.slice(0, 10)}</small>` : "";
76
+ // Surface a small "origin" hint in the list — the source:* tag if
77
+ // present, otherwise blank. Full provenance lives on the detail view.
78
+ const sourceTag = (m.tags ?? []).find((t) => t.startsWith("source:"));
79
+ const origin = sourceTag
80
+ ? `<span class="badge badge-yellow">${esc(sourceTag.replace("source:", ""))}</span>`
81
+ : "—";
51
82
  tableRows += `
52
83
  <tr>
53
- <td style="max-width:400px">${esc(preview)}</td>
84
+ <td style="max-width:380px"><a href="/AdminMemory?id=${esc(m.id)}" style="color:#2563eb;text-decoration:none">${esc(preview)}</a></td>
54
85
  <td>${durBadge}</td>
55
86
  <td>${esc(subjectStr)}</td>
87
+ <td>${origin}</td>
56
88
  <td>${esc(m.agentId ?? "—")}</td>
57
89
  <td>${created} ${validity}</td>
58
90
  </tr>`;
@@ -78,6 +110,7 @@ export class AdminMemory extends Resource {
78
110
  <th>Content</th>
79
111
  <th>Durability</th>
80
112
  <th>Subject</th>
113
+ <th>Origin</th>
81
114
  <th>Agent</th>
82
115
  <th>Created</th>
83
116
  </tr>
@@ -89,4 +122,210 @@ export class AdminMemory extends Resource {
89
122
  `;
90
123
  return htmlResponse(layout("Memory", content, "memory"));
91
124
  }
125
+ // ─── Detail view ───────────────────────────────────────────────────────────
126
+ async renderDetail(id) {
127
+ const memDb = databases.flair.Memory;
128
+ let memory = null;
129
+ try {
130
+ memory = await memDb.get(id);
131
+ }
132
+ catch { /* table missing or other error */ }
133
+ if (!memory) {
134
+ const notFound = `
135
+ <h1>Memory not found</h1>
136
+ <p class="subtitle">No memory with id <code>${esc(id)}</code>.</p>
137
+ <p><a href="/AdminMemory" class="btn btn-primary">Back to memory list</a></p>
138
+ `;
139
+ return htmlResponse(layout("Memory not found", notFound, "memory"));
140
+ }
141
+ // Lookup memories that supersede this one (reverse of supersedes field).
142
+ const supersededBy = [];
143
+ try {
144
+ for await (const m of memDb.search({ conditions: [{ attribute: "supersedes", comparator: "equals", value: id }] })) {
145
+ supersededBy.push(m);
146
+ if (supersededBy.length >= 5)
147
+ break;
148
+ }
149
+ }
150
+ catch { /* swallow */ }
151
+ // Reverse-lookup: memories whose derivedFrom contains this id.
152
+ // Note: derivedFrom is an array; Harper's contains-on-array search via
153
+ // search_by_value is used here. If unavailable, leave empty.
154
+ const derivatives = [];
155
+ try {
156
+ for await (const m of memDb.search({ conditions: [{ attribute: "derivedFrom", comparator: "contains", value: id }] })) {
157
+ derivatives.push(m);
158
+ if (derivatives.length >= 5)
159
+ break;
160
+ }
161
+ }
162
+ catch { /* swallow */ }
163
+ const content = this.renderProvenancePane(memory, supersededBy, derivatives);
164
+ return htmlResponse(layout(`Memory ${id.slice(0, 8)}…`, content, "memory"));
165
+ }
166
+ // ─── Federation provenance card ────────────────────────────────────────────
167
+ /**
168
+ * Surface the federation-write fields stamped by resources/Federation.ts
169
+ * (lines 392–394) when a record arrived from a peer. Local-origin
170
+ * memories don't have these fields set; we render an explicit
171
+ * "local origin" note in that case so the absence is legible.
172
+ */
173
+ renderFederationInfo(m) {
174
+ const orig = m._originatorInstanceId;
175
+ const from = m._syncedFrom;
176
+ const at = m._syncedAt;
177
+ if (!orig && !from && !at) {
178
+ return "<em style='color:#888'>local origin — not synced from a peer</em>";
179
+ }
180
+ const rows = [];
181
+ if (orig) {
182
+ rows.push(`<dt style="color:#666">Originator instance</dt><dd><code>${esc(orig)}</code></dd>`);
183
+ }
184
+ if (from && from !== orig) {
185
+ // syncedFrom and originator differ when a hub relayed from a third spoke
186
+ rows.push(`<dt style="color:#666">Synced from peer</dt><dd><code>${esc(from)}</code> <small style="color:#888">(relayed)</small></dd>`);
187
+ }
188
+ else if (from) {
189
+ rows.push(`<dt style="color:#666">Synced from peer</dt><dd><code>${esc(from)}</code></dd>`);
190
+ }
191
+ if (at) {
192
+ rows.push(`<dt style="color:#666">Synced at</dt><dd>${esc(at)}</dd>`);
193
+ }
194
+ return `<dl style="display:grid;grid-template-columns:200px 1fr;gap:8px;margin-top:8px">
195
+ ${rows.join("\n ")}
196
+ </dl>`;
197
+ }
198
+ // ─── Provenance pane HTML ──────────────────────────────────────────────────
199
+ renderProvenancePane(m, supersededBy, derivatives) {
200
+ const tags = Array.isArray(m.tags) ? m.tags : [];
201
+ const sourceTag = tags.find((t) => t.startsWith("source:"));
202
+ const importTag = tags.find((t) => t.startsWith("import:"));
203
+ const otherTags = tags.filter((t) => !t.startsWith("source:") && !t.startsWith("import:"));
204
+ const tagChips = [
205
+ sourceTag ? `<span class="badge badge-yellow" title="origin">${esc(sourceTag)}</span>` : "",
206
+ importTag ? `<span class="badge badge-blue" title="import path">${esc(importTag)}</span>` : "",
207
+ ...otherTags.map((t) => `<span class="badge badge-gray">${esc(t)}</span>`),
208
+ ].filter(Boolean).join(" ");
209
+ // Lineage links (derivedFrom + parentId + supersedes pointers)
210
+ const derivedFrom = Array.isArray(m.derivedFrom) ? m.derivedFrom : [];
211
+ const derivedFromLinks = derivedFrom.length > 0
212
+ ? derivedFrom.map((d) => `<a href="/AdminMemory?id=${esc(d)}" style="color:#2563eb">${esc(d.slice(0, 12))}…</a>`).join(", ")
213
+ : "<em style='color:#888'>none</em>";
214
+ const parentLink = m.parentId
215
+ ? `<a href="/AdminMemory?id=${esc(m.parentId)}" style="color:#2563eb">${esc(m.parentId.slice(0, 12))}…</a>`
216
+ : "<em style='color:#888'>none</em>";
217
+ const supersedesLink = m.supersedes
218
+ ? `<a href="/AdminMemory?id=${esc(m.supersedes)}" style="color:#2563eb">${esc(m.supersedes.slice(0, 12))}…</a>`
219
+ : "<em style='color:#888'>nothing — this is an original</em>";
220
+ const supersededByLinks = supersededBy.length > 0
221
+ ? supersededBy.map((s) => `<a href="/AdminMemory?id=${esc(s.id)}" style="color:#2563eb">${esc(s.id.slice(0, 12))}…</a>`).join(", ")
222
+ : "<em style='color:#888'>nothing — this is current</em>";
223
+ const derivativesLinks = derivatives.length > 0
224
+ ? derivatives.map((d) => `<a href="/AdminMemory?id=${esc(d.id)}" style="color:#2563eb">${esc(d.id.slice(0, 12))}…</a>`).join(", ")
225
+ : "<em style='color:#888'>none</em>";
226
+ // Promotion details (if promoted)
227
+ const promotionInfo = m.promotionStatus
228
+ ? `<dl style="display:grid;grid-template-columns:140px 1fr;gap:8px;margin-top:8px">
229
+ <dt style="color:#666">Status</dt><dd>${esc(m.promotionStatus)}</dd>
230
+ ${m.promotedAt ? `<dt style="color:#666">Promoted at</dt><dd>${esc(m.promotedAt)}</dd>` : ""}
231
+ ${m.promotedBy ? `<dt style="color:#666">Promoted by</dt><dd>${esc(m.promotedBy)}</dd>` : ""}
232
+ </dl>`
233
+ : "<em style='color:#888'>not promoted</em>";
234
+ // Archive details
235
+ const archiveInfo = m.archived
236
+ ? `<dl style="display:grid;grid-template-columns:140px 1fr;gap:8px;margin-top:8px">
237
+ <dt style="color:#666">Archived at</dt><dd>${esc(m.archivedAt ?? "—")}</dd>
238
+ <dt style="color:#666">Archived by</dt><dd>${esc(m.archivedBy ?? "—")}</dd>
239
+ </dl>`
240
+ : "<em style='color:#888'>active</em>";
241
+ const safetyFlags = Array.isArray(m._safetyFlags) ? m._safetyFlags : [];
242
+ const safetyDisplay = safetyFlags.length > 0
243
+ ? safetyFlags.map((f) => `<span class="badge badge-yellow">${esc(f)}</span>`).join(" ")
244
+ : "<em style='color:#888'>clean</em>";
245
+ return `
246
+ <div style="margin-bottom:16px">
247
+ <a href="/AdminMemory" style="color:#666;text-decoration:none;font-size:0.9em">← Back to memory list</a>
248
+ </div>
249
+
250
+ <h1>Memory ${esc(m.id.slice(0, 8))}…</h1>
251
+ <p class="subtitle">
252
+ agentId: <code>${esc(m.agentId ?? "—")}</code> ·
253
+ subject: ${m.subject ? `<code>${esc(m.subject)}</code>` : "—"}
254
+ </p>
255
+
256
+ <div class="card">
257
+ <h3>Content</h3>
258
+ <p style="white-space:pre-wrap;font-family:ui-monospace,SF Mono,monospace;font-size:0.95em;line-height:1.5;margin-top:8px">${esc(m.content ?? "")}</p>
259
+ ${m.summary ? `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #eee"><strong style="color:#666;font-size:0.85em">SUMMARY</strong><p style="margin-top:4px">${esc(m.summary)}</p></div>` : ""}
260
+ </div>
261
+
262
+ <div class="card">
263
+ <h3>Tags</h3>
264
+ <div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
265
+ ${tagChips || "<em style='color:#888'>untagged</em>"}
266
+ </div>
267
+ </div>
268
+
269
+ <div class="card">
270
+ <h3>Lineage</h3>
271
+ <dl style="display:grid;grid-template-columns:200px 1fr;gap:10px;margin-top:8px">
272
+ <dt style="color:#666">Derived from</dt><dd>${derivedFromLinks}</dd>
273
+ <dt style="color:#666">Parent</dt><dd>${parentLink}</dd>
274
+ <dt style="color:#666">Supersedes</dt><dd>${supersedesLink}</dd>
275
+ <dt style="color:#666">Superseded by</dt><dd>${supersededByLinks}</dd>
276
+ <dt style="color:#666">Derivatives (downstream)</dt><dd>${derivativesLinks}</dd>
277
+ ${m.sessionId ? `<dt style="color:#666">Session</dt><dd><code>${esc(m.sessionId)}</code></dd>` : ""}
278
+ </dl>
279
+ </div>
280
+
281
+ <div class="card">
282
+ <h3>Lifecycle</h3>
283
+ <dl style="display:grid;grid-template-columns:200px 1fr;gap:10px;margin-top:8px">
284
+ <dt style="color:#666">Durability</dt><dd>${esc(m.durability ?? "standard")}</dd>
285
+ <dt style="color:#666">Created</dt><dd>${esc(m.createdAt ?? "—")}</dd>
286
+ <dt style="color:#666">Updated</dt><dd>${esc(m.updatedAt ?? "—")}</dd>
287
+ <dt style="color:#666">Valid from</dt><dd>${esc(m.validFrom ?? "—")}</dd>
288
+ <dt style="color:#666">Valid to</dt><dd>${esc(m.validTo ?? "still valid")}</dd>
289
+ <dt style="color:#666">Expires</dt><dd>${esc(m.expiresAt ?? "never")}</dd>
290
+ </dl>
291
+ </div>
292
+
293
+ <div class="card">
294
+ <h3>Federation</h3>
295
+ ${this.renderFederationInfo(m)}
296
+ </div>
297
+
298
+ <div class="card">
299
+ <h3>Promotion</h3>
300
+ ${promotionInfo}
301
+ </div>
302
+
303
+ <div class="card">
304
+ <h3>Archive</h3>
305
+ ${archiveInfo}
306
+ </div>
307
+
308
+ <div class="card">
309
+ <h3>Usage</h3>
310
+ <dl style="display:grid;grid-template-columns:200px 1fr;gap:10px;margin-top:8px">
311
+ <dt style="color:#666">Retrievals</dt><dd>${m.retrievalCount ?? 0}</dd>
312
+ <dt style="color:#666">Last retrieved</dt><dd>${esc(m.lastRetrieved ?? "—")}</dd>
313
+ </dl>
314
+ </div>
315
+
316
+ <div class="card">
317
+ <h3>Safety</h3>
318
+ ${safetyDisplay}
319
+ </div>
320
+
321
+ <div class="card" style="background:#f8f9fa">
322
+ <h3 style="color:#666;font-size:0.9em">RAW IDENTITY</h3>
323
+ <dl style="display:grid;grid-template-columns:200px 1fr;gap:6px;margin-top:8px;font-family:ui-monospace,SF Mono,monospace;font-size:0.85em">
324
+ <dt style="color:#666">id</dt><dd>${esc(m.id)}</dd>
325
+ <dt style="color:#666">contentHash</dt><dd>${esc(m.contentHash ?? "—")}</dd>
326
+ <dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "private")}</dd>
327
+ </dl>
328
+ </div>
329
+ `;
330
+ }
92
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",