@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 +61 -8
- package/dist/bridges/builtins/chatgpt.js +220 -0
- package/dist/bridges/builtins/claude-project.js +275 -0
- package/dist/bridges/builtins/index.js +6 -0
- package/dist/bridges/builtins/mem0.js +247 -0
- package/dist/cli.js +141 -0
- package/dist/resources/AdminMemory.js +243 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,23 +4,76 @@
|
|
|
4
4
|
[](https://github.com/tpsdev-ai/flair/actions/workflows/docker-test.yml)
|
|
5
5
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
> **The memory system that gives agents a durable identity, not just durable facts. Federated. Crypto-isolated. Yours.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Most agent memory systems store *facts*. Flair stores facts AND the agent's identity — its 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
|
-
|
|
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
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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 /AdminMemory
|
|
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="
|
|
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,
|
|
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:
|
|
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.
|
|
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",
|