@tpsdev-ai/openclaw-flair 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @tpsdev-ai/openclaw-flair
2
+
3
+ OpenClaw memory plugin for Flair — agent identity and semantic memory. Replaces the built-in `MEMORY.md` / `memory-lancedb` system with [Flair](https://github.com/tpsdev-ai/flair) as the single source of truth for agent memory.
4
+
5
+ Uses Flair's native Harper vector embeddings — no OpenAI API key required.
6
+
7
+ ## Features
8
+
9
+ - **Semantic search** via `memory_recall` → Flair's HNSW vector index
10
+ - **Persistent storage** via `memory_store` → Ed25519-authenticated writes
11
+ - **Memory retrieval** via `memory_get` → fetch by ID
12
+ - **Auto-bootstrap** — injects relevant memories into context at session start
13
+ - **Auto-capture** — automatically stores important information from conversations
14
+ - **Multi-agent** — `agentId: "auto"` resolves per-session for shared gateways
15
+ - **Durability levels** — permanent, persistent, standard, ephemeral
16
+ - **Memory versioning** — `supersedes` field creates version chains
17
+
18
+ ## Prerequisites
19
+
20
+ - A running [Flair](https://github.com/tpsdev-ai/flair) instance (Harper v5+)
21
+ - An agent record in Flair with an Ed25519 public key
22
+ - The corresponding private key at `~/.tps/secrets/flair/<agentId>-priv.key`
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ # From npm (when published)
28
+ openclaw plugin install @tpsdev-ai/openclaw-flair
29
+
30
+ # From source
31
+ cd plugins/openclaw-memory
32
+ npm install
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ In your OpenClaw config (`openclaw.json`):
38
+
39
+ ```json
40
+ {
41
+ "plugins": {
42
+ "allow": ["memory-flair"],
43
+ "slots": {
44
+ "memory": "memory-flair"
45
+ },
46
+ "entries": {
47
+ "memory-flair": {
48
+ "enabled": true,
49
+ "config": {
50
+ "url": "http://localhost:9926",
51
+ "agentId": "auto",
52
+ "autoCapture": true,
53
+ "autoRecall": true,
54
+ "maxRecallResults": 5,
55
+ "maxBootstrapTokens": 4000
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Config Options
64
+
65
+ | Option | Type | Default | Description |
66
+ |--------|------|---------|-------------|
67
+ | `url` | string | `http://127.0.0.1:9926` | Flair server URL |
68
+ | `agentId` | string | *required* | Agent ID for memory namespacing. Use `"auto"` for multi-agent gateways. |
69
+ | `keyPath` | string | auto-resolved | Path to Ed25519 private key |
70
+ | `autoCapture` | boolean | `true` | Auto-capture important info from conversations |
71
+ | `autoRecall` | boolean | `true` | Inject relevant memories at session start |
72
+ | `maxRecallResults` | number | `5` | Max results for `memory_recall` |
73
+ | `maxBootstrapTokens` | number | `4000` | Max tokens for bootstrap context injection |
74
+
75
+ ## Auth
76
+
77
+ Uses TPS Ed25519 signatures. The plugin looks for private keys in this order:
78
+
79
+ 1. `keyPath` from config (if explicitly set)
80
+ 2. `$FLAIR_KEY_DIR/<agentId>.key` (if `FLAIR_KEY_DIR` env var is set)
81
+ 3. `~/.flair/keys/<agentId>.key` (**standard path** — use `flair init` to generate)
82
+ 4. `~/.tps/secrets/flair/<agentId>-priv.key` *(legacy — deprecated, will warn)*
83
+ 5. `~/.tps/secrets/<agentId>-flair.key` *(legacy — deprecated, will warn)*
84
+
85
+ Key files may be raw 32-byte binary seeds (written by `flair init`) or base64-encoded seeds (legacy format). Both are supported.
86
+
87
+ ## License
88
+
89
+ Apache-2.0
package/index.ts ADDED
@@ -0,0 +1,511 @@
1
+ /**
2
+ * memory-flair — OpenClaw Memory Plugin backed by Flair
3
+ *
4
+ * Replaces the built-in MEMORY.md / memory-lancedb system with Flair as the
5
+ * single source of truth for agent memory. Uses Flair's native Harper
6
+ * embeddings — no OpenAI API key required.
7
+ *
8
+ * Implements the OpenClaw "memory" plugin slot:
9
+ * - memory_recall → POST /SemanticSearch (semantic search)
10
+ * - memory_store → PUT /Memory/<id> (write + embed)
11
+ * - memory_get → GET /Memory/<id> (fetch by id)
12
+ * - before_agent_start hook → inject recent/relevant memories
13
+ * - agent_end hook → auto-capture from conversation
14
+ */
15
+
16
+ import { randomUUID, createHash } from "node:crypto";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { resolve, basename } from "node:path";
20
+ import { Type } from "@sinclair/typebox";
21
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
22
+
23
+ // ─── Config ──────────────────────────────────────────────────────────────────
24
+
25
+ interface FlairMemoryConfig {
26
+ url?: string;
27
+ agentId: string;
28
+ keyPath?: string;
29
+ autoCapture?: boolean;
30
+ autoRecall?: boolean;
31
+ maxRecallResults?: number;
32
+ maxBootstrapTokens?: number;
33
+ }
34
+
35
+ const DEFAULT_URL = "http://127.0.0.1:9926";
36
+ const DEFAULT_MAX_RECALL = 5;
37
+ const DEFAULT_MAX_BOOTSTRAP_TOKENS = 4000;
38
+
39
+ // ─── Flair HTTP Client ────────────────────────────────────────────────────────
40
+
41
+ class FlairMemoryClient {
42
+ private readonly baseUrl: string;
43
+ private readonly agentId: string;
44
+ private readonly keyPath: string | null;
45
+
46
+ constructor(config: FlairMemoryConfig) {
47
+ this.baseUrl = (config.url ?? DEFAULT_URL).replace(/\/$/, "");
48
+ this.agentId = config.agentId;
49
+ this.keyPath = config.keyPath
50
+ ? resolve(config.keyPath.replace(/^~/, homedir()))
51
+ : this.resolveDefaultKey(config.agentId);
52
+ }
53
+
54
+ private resolveDefaultKey(agentId: string): string | null {
55
+ // 1. FLAIR_KEY_DIR env var (if set)
56
+ const keyDirEnv = process.env.FLAIR_KEY_DIR;
57
+ if (keyDirEnv) {
58
+ const envPath = resolve(keyDirEnv, `${agentId}.key`);
59
+ if (existsSync(envPath)) return envPath;
60
+ }
61
+
62
+ // 2. ~/.flair/keys/<agent>.key (new standard path)
63
+ const newStandard = resolve(homedir(), ".flair", "keys", `${agentId}.key`);
64
+ if (existsSync(newStandard)) return newStandard;
65
+
66
+ // 3. Legacy paths (backwards compat — warn on first use)
67
+ const legacyCandidates = [
68
+ resolve(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
69
+ resolve(homedir(), ".tps", "secrets", `${agentId}-flair.key`),
70
+ ];
71
+ const legacyPath = legacyCandidates.find(existsSync);
72
+ if (legacyPath) {
73
+ if (!FlairMemoryClient._legacyKeyWarned.has(agentId)) {
74
+ console.warn(
75
+ `[flair-plugin] DEPRECATION: key at legacy path ${legacyPath}. ` +
76
+ `Move to ~/.flair/keys/${agentId}.key or set FLAIR_KEY_DIR.`
77
+ );
78
+ FlairMemoryClient._legacyKeyWarned.add(agentId);
79
+ }
80
+ return legacyPath;
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ private static _legacyKeyWarned = new Set<string>();
87
+
88
+ private buildAuthHeader(method: string, path: string): Record<string, string> {
89
+ if (!this.keyPath || !existsSync(this.keyPath)) return {};
90
+ try {
91
+ const { sign: ed25519Sign, createPrivateKey, randomUUID: rv } = require("node:crypto");
92
+ // Read raw bytes — supports both:
93
+ // - 32-byte binary seed (written by `flair init`)
94
+ // - base64-encoded seed (legacy format)
95
+ const fileBuf = readFileSync(this.keyPath);
96
+ let rawBuf: Buffer;
97
+ if (fileBuf.length === 32) {
98
+ // Raw binary seed
99
+ rawBuf = fileBuf;
100
+ } else {
101
+ // Try base64 decode
102
+ rawBuf = Buffer.from(fileBuf.toString("utf-8").trim(), "base64");
103
+ }
104
+ let privateKey: ReturnType<typeof createPrivateKey>;
105
+ if (rawBuf.length === 32) {
106
+ // Raw Ed25519 seed — wrap in PKCS8 DER envelope
107
+ const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
108
+ privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, rawBuf]), format: "der", type: "pkcs8" });
109
+ } else {
110
+ privateKey = createPrivateKey({ key: rawBuf, format: "der", type: "pkcs8" });
111
+ }
112
+ const ts = Date.now().toString();
113
+ const nonce = rv();
114
+ const payload = `${this.agentId}:${ts}:${nonce}:${method}:${path}`;
115
+ const sig = ed25519Sign(null, Buffer.from(payload), privateKey);
116
+ return { Authorization: `TPS-Ed25519 ${this.agentId}:${ts}:${nonce}:${sig.toString("base64")}` };
117
+ } catch {
118
+ return {};
119
+ }
120
+ }
121
+
122
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
123
+ const res = await fetch(`${this.baseUrl}${path}`, {
124
+ method,
125
+ headers: {
126
+ "Content-Type": "application/json",
127
+ ...this.buildAuthHeader(method, path),
128
+ },
129
+ body: body !== undefined ? JSON.stringify(body) : undefined,
130
+ signal: AbortSignal.timeout(10_000),
131
+ });
132
+ if (!res.ok) {
133
+ const text = await res.text().catch(() => "");
134
+ throw new Error(`Flair ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
135
+ }
136
+ const text = await res.text();
137
+ return text ? (JSON.parse(text) as T) : ({} as T);
138
+ }
139
+
140
+ async writeMemory(id: string, content: string, opts: { durability?: string; type?: string; tags?: string[]; supersedes?: string } = {}): Promise<void> {
141
+ const record: Record<string, unknown> = {
142
+ id,
143
+ agentId: this.agentId,
144
+ content,
145
+ durability: opts.durability ?? "standard",
146
+ type: opts.type ?? "session",
147
+ createdAt: new Date().toISOString(),
148
+ };
149
+ if (opts.tags?.length) record.tags = opts.tags;
150
+ if (opts.supersedes) record.supersedes = opts.supersedes;
151
+ await this.request("PUT", `/Memory/${id}`, record);
152
+ }
153
+
154
+ async searchMemories(query: string, limit: number): Promise<Array<{ id: string; content: string; score: number; tags?: string[] }>> {
155
+ const result = await this.request<{ results?: Array<{ id: string; content: string; score?: number; similarity?: number; memory?: { id: string; content: string; tags?: string[] } }> }>(
156
+ "POST",
157
+ "/SemanticSearch",
158
+ { agentId: this.agentId, q: query, limit },
159
+ );
160
+ return (result.results ?? []).map((r) => ({
161
+ id: r.id ?? r.memory?.id ?? "",
162
+ content: r.content ?? r.memory?.content ?? "",
163
+ score: r.score ?? r.similarity ?? 0,
164
+ tags: r.memory?.tags,
165
+ }));
166
+ }
167
+
168
+ async getMemory(id: string): Promise<{ id: string; content: string; createdAt?: string } | null> {
169
+ try {
170
+ return await this.request("GET", `/Memory/${id}`);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ async bootstrap(opts: { days?: number } = {}): Promise<string> {
177
+ try {
178
+ const days = opts.days ?? 7;
179
+ const since = new Date(Date.now() - days * 86_400_000).toISOString();
180
+ const result = await this.request<{ context?: string; text?: string }>(
181
+ "POST",
182
+ "/BootstrapMemories",
183
+ { agentId: this.agentId, since },
184
+ );
185
+ return result.context ?? result.text ?? "";
186
+ } catch {
187
+ return "";
188
+ }
189
+ }
190
+
191
+ async getSoul(key: string): Promise<{ id: string; key: string; value: string; contentHash?: string } | null> {
192
+ try {
193
+ return await this.request("GET", `/Soul/${this.agentId}-${key}`);
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ async writeSoul(key: string, value: string, contentHash?: string): Promise<void> {
200
+ const record: Record<string, unknown> = {
201
+ id: `${this.agentId}-${key}`,
202
+ agentId: this.agentId,
203
+ key,
204
+ value,
205
+ durability: "permanent",
206
+ createdAt: new Date().toISOString(),
207
+ };
208
+ if (contentHash) record.contentHash = contentHash;
209
+ await this.request("PUT", `/Soul/${this.agentId}-${key}`, record);
210
+ }
211
+ }
212
+
213
+ // ─── Workspace sync helpers ───────────────────────────────────────────────────
214
+
215
+ /** Files to sync from workspace to Flair soul entries (spec: OPS-workspace-sync) */
216
+ const WORKSPACE_SOUL_FILES: Record<string, string> = {
217
+ "SOUL.md": "soul",
218
+ "IDENTITY.md": "identity",
219
+ "USER.md": "user-context",
220
+ "AGENTS.md": "workspace-rules",
221
+ };
222
+
223
+ /** Max size for a single soul entry (chars). Files larger are truncated. */
224
+ const MAX_SOUL_VALUE = 8000;
225
+
226
+ function hashContent(content: string): string {
227
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
228
+ }
229
+
230
+ /**
231
+ * Sync workspace files to Flair soul entries.
232
+ * Only writes when content has changed (hash comparison).
233
+ */
234
+ async function syncWorkspaceToFlair(
235
+ client: FlairMemoryClient,
236
+ agentId: string,
237
+ logger: { info: Function; warn: Function },
238
+ ): Promise<number> {
239
+ const workspace = resolve(homedir(), ".openclaw", `workspace-${agentId}`);
240
+ if (!existsSync(workspace)) return 0;
241
+
242
+ let synced = 0;
243
+ for (const [filename, soulKey] of Object.entries(WORKSPACE_SOUL_FILES)) {
244
+ const filePath = resolve(workspace, filename);
245
+ if (!existsSync(filePath)) continue;
246
+
247
+ try {
248
+ let content = readFileSync(filePath, "utf-8").trim();
249
+ if (!content) continue;
250
+ if (content.length > MAX_SOUL_VALUE) content = content.slice(0, MAX_SOUL_VALUE) + "\n…(truncated)";
251
+
252
+ const newHash = hashContent(content);
253
+
254
+ // Check existing entry's hash
255
+ const existing = await client.getSoul(soulKey);
256
+ if (existing?.contentHash === newHash) continue; // unchanged
257
+
258
+ await client.writeSoul(soulKey, content, newHash);
259
+ synced++;
260
+ logger.info(`memory-flair: synced ${filename} → soul:${soulKey} (hash=${newHash})`);
261
+ } catch (err: any) {
262
+ logger.warn(`memory-flair: failed to sync ${filename}: ${err.message}`);
263
+ }
264
+ }
265
+ return synced;
266
+ }
267
+
268
+ // ─── Auto-capture helpers ─────────────────────────────────────────────────────
269
+
270
+ const CAPTURE_TRIGGERS = [
271
+ /\b(remember|note that|important:|keep in mind|don't forget)\b/i,
272
+ /\b(preference|prefer|always|never|my name is|i am|i'm)\b/i,
273
+ /\b(decided|agreed|confirmed|finalized)\b/i,
274
+ ];
275
+
276
+ function shouldCapture(text: string): boolean {
277
+ return CAPTURE_TRIGGERS.some((re) => re.test(text));
278
+ }
279
+
280
+ function excerptForCapture(text: string, maxChars = 500): string {
281
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
282
+ }
283
+
284
+ // ─── Plugin export ────────────────────────────────────────────────────────────
285
+
286
+ export default {
287
+ kind: "memory" as const,
288
+
289
+ register(api: OpenClawPluginApi) {
290
+ try {
291
+ const cfg = api.pluginConfig as FlairMemoryConfig;
292
+ const isAutoMode = !cfg.agentId || cfg.agentId === "auto";
293
+
294
+ // Client pool: one client per agentId, created lazily
295
+ const clientPool = new Map<string, FlairMemoryClient>();
296
+
297
+ function getClient(agentId?: string): FlairMemoryClient {
298
+ const id = agentId || cfg.agentId;
299
+ if (!id || id === "auto") throw new Error("no agentId available");
300
+ let client = clientPool.get(id);
301
+ if (!client) {
302
+ client = new FlairMemoryClient({ ...cfg, agentId: id });
303
+ clientPool.set(id, client);
304
+ }
305
+ return client;
306
+ }
307
+
308
+ // For non-auto mode, pre-create the client
309
+ if (!isAutoMode) {
310
+ getClient(cfg.agentId);
311
+ }
312
+
313
+ api.logger.info("memory-flair: config ok, creating client...");
314
+ api.logger.info("memory-flair: client created");
315
+ const maxRecall = cfg.maxRecallResults ?? DEFAULT_MAX_RECALL;
316
+ const autoCapture = cfg.autoCapture ?? true;
317
+ const autoRecall = cfg.autoRecall ?? true;
318
+
319
+ // Track current agent per-session via hooks (tools don't get agentId in execute)
320
+ let currentAgentId: string | undefined = isAutoMode ? undefined : cfg.agentId;
321
+
322
+ api.on("before_agent_start", async (event: any, ctx: any) => {
323
+ const eventAgentId = ctx?.agentId || (event as any).agentId;
324
+ if (eventAgentId) currentAgentId = eventAgentId;
325
+
326
+ // Sync workspace files → Flair soul entries (hash-based, only on change)
327
+ if (eventAgentId) {
328
+ try {
329
+ const client = getClient(eventAgentId);
330
+ const synced = await syncWorkspaceToFlair(client, eventAgentId, api.logger);
331
+ if (synced > 0) api.logger.info(`memory-flair: workspace sync: ${synced} files updated`);
332
+ } catch (err: any) {
333
+ api.logger.warn(`memory-flair: workspace sync failed: ${err.message}`);
334
+ }
335
+ }
336
+ });
337
+
338
+ // Helper to get client using tracked agentId
339
+ function getCurrentClient(): FlairMemoryClient {
340
+ return getClient(currentAgentId);
341
+ }
342
+
343
+ const displayAgent = isAutoMode ? "auto (per-session)" : cfg.agentId;
344
+ api.logger.info(`memory-flair: registered (agent=${displayAgent}, url=${cfg.url ?? DEFAULT_URL})`);
345
+
346
+ // ── memory_recall ──────────────────────────────────────────────────────
347
+
348
+ api.registerTool(
349
+ {
350
+ name: "memory_recall",
351
+ label: "Memory Recall",
352
+ description:
353
+ "Search long-term memory via Flair semantic search. Use when you need context about user preferences, past decisions, or previously discussed topics.",
354
+ parameters: Type.Object({
355
+ query: Type.String({ description: "Search query" }),
356
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
357
+ }),
358
+ async execute(_id, params) {
359
+ const { query, limit = maxRecall } = params as { query: string; limit?: number };
360
+ try {
361
+ const client = getCurrentClient();
362
+ const results = await client.searchMemories(query, limit);
363
+ if (results.length === 0) {
364
+ return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
365
+ }
366
+ const text = results
367
+ .map((r, i) => `${i + 1}. ${r.content} (${(r.score * 100).toFixed(0)}%)`)
368
+ .join("\n");
369
+ return {
370
+ content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }],
371
+ details: { count: results.length, memories: results },
372
+ };
373
+ } catch (err: any) {
374
+ api.logger.warn(`memory-flair: recall failed: ${err.message}`);
375
+ return { content: [{ type: "text", text: `Memory recall unavailable: ${err.message}` }], details: { count: 0 } };
376
+ }
377
+ },
378
+ },
379
+ { name: "memory_recall" },
380
+ );
381
+
382
+ // ── memory_store ───────────────────────────────────────────────────────
383
+
384
+ api.registerTool(
385
+ {
386
+ name: "memory_store",
387
+ label: "Memory Store",
388
+ description: "Save important information in long-term memory via Flair. Use for preferences, facts, decisions, and key context.",
389
+ parameters: Type.Object({
390
+ text: Type.String({ description: "Information to remember" }),
391
+ importance: Type.Optional(Type.Number({ description: "Importance 0-1 (default: 0.7)" })),
392
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Optional tags" })),
393
+ durability: Type.Optional(Type.Union([
394
+ Type.Literal("permanent"),
395
+ Type.Literal("persistent"),
396
+ Type.Literal("standard"),
397
+ Type.Literal("ephemeral"),
398
+ ], { description: "Memory durability: permanent (inviolable), persistent (key decisions), standard (default), ephemeral (auto-expires)" })),
399
+ type: Type.Optional(Type.Union([
400
+ Type.Literal("session"),
401
+ Type.Literal("lesson"),
402
+ Type.Literal("decision"),
403
+ Type.Literal("preference"),
404
+ Type.Literal("fact"),
405
+ Type.Literal("goal"),
406
+ ], { description: "Memory type for categorization" })),
407
+ supersedes: Type.Optional(Type.String({ description: "ID of memory this replaces (creates version chain)" })),
408
+ }),
409
+ async execute(_id, params) {
410
+ const { text, tags, durability, type, supersedes } = params as {
411
+ text: string; importance?: number; tags?: string[];
412
+ durability?: string; type?: string; supersedes?: string;
413
+ };
414
+ try {
415
+ const agentId = currentAgentId || cfg.agentId;
416
+ const client = getCurrentClient();
417
+ const memId = `${agentId}-${Date.now()}`;
418
+ await client.writeMemory(memId, text, { tags, durability, type, supersedes });
419
+ return {
420
+ content: [{ type: "text", text: `Memory stored (id: ${memId})` }],
421
+ details: { id: memId },
422
+ };
423
+ } catch (err: any) {
424
+ api.logger.warn(`memory-flair: store failed: ${err.message}`);
425
+ return { content: [{ type: "text", text: `Memory store unavailable: ${err.message}` }], details: {} };
426
+ }
427
+ },
428
+ },
429
+ { name: "memory_store" },
430
+ );
431
+
432
+ // ── memory_get ─────────────────────────────────────────────────────────
433
+
434
+ api.registerTool(
435
+ {
436
+ name: "memory_get",
437
+ label: "Memory Get",
438
+ description: "Retrieve a specific memory by ID from Flair.",
439
+ parameters: Type.Object({
440
+ id: Type.String({ description: "Memory ID" }),
441
+ }),
442
+ async execute(_toolId, params) {
443
+ const { id } = params as { id: string };
444
+ try {
445
+ const client = getCurrentClient();
446
+ const mem = await client.getMemory(id);
447
+ if (!mem) return { content: [{ type: "text", text: `Memory ${id} not found.` }], details: {} };
448
+ return {
449
+ content: [{ type: "text", text: mem.content }],
450
+ details: mem,
451
+ };
452
+ } catch (err: any) {
453
+ return { content: [{ type: "text", text: `Memory get failed: ${err.message}` }], details: {} };
454
+ }
455
+ },
456
+ },
457
+ { name: "memory_get" },
458
+ );
459
+
460
+ // ── Lifecycle: auto-recall on session start ────────────────────────────
461
+
462
+ if (autoRecall) {
463
+ api.on("before_agent_start", async (event: any, ctx: any) => {
464
+ try {
465
+ // Ensure agentId is set (in case this fires before the tracking hook)
466
+ if (ctx?.agentId && !currentAgentId) currentAgentId = ctx.agentId;
467
+ const client = getCurrentClient();
468
+ const context = await client.bootstrap({ days: 7 });
469
+ if (context && typeof context === "string" && context.trim().length > 0) {
470
+ const truncated = context.slice(0, (cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS) * 4);
471
+ event.injectContext?.(`\n## Memory Context (from Flair)\n\n${truncated}\n`);
472
+ api.logger.info(`memory-flair: injected bootstrap context (${context.length} chars)`);
473
+ }
474
+ } catch (err: any) {
475
+ api.logger.warn(`memory-flair: bootstrap recall failed: ${err.message}`);
476
+ }
477
+ });
478
+ }
479
+
480
+ // ── Lifecycle: auto-capture on session end ────────────────────────────
481
+
482
+ if (autoCapture) {
483
+ api.on("agent_end", async (event) => {
484
+ try {
485
+ const agentId = currentAgentId || cfg.agentId;
486
+ const client = getCurrentClient();
487
+ const messages = (event.messages ?? []) as Array<{ role: string; content?: string }>;
488
+ let stored = 0;
489
+ for (const msg of messages) {
490
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
491
+ const text = typeof msg.content === "string" ? msg.content : "";
492
+ if (!text || !shouldCapture(text)) continue;
493
+ const excerpt = excerptForCapture(text);
494
+ const memId = `${agentId}-${Date.now()}-${stored}`;
495
+ await client.writeMemory(memId, excerpt, { type: "session", tags: ["auto-captured"] });
496
+ stored++;
497
+ if (stored >= 3) break; // cap at 3 per session
498
+ }
499
+ if (stored > 0) api.logger.info(`memory-flair: auto-captured ${stored} memories`);
500
+ } catch (err: any) {
501
+ api.logger.warn(`memory-flair: auto-capture failed: ${err.message}`);
502
+ }
503
+ });
504
+ }
505
+
506
+ } catch (err: any) {
507
+ api.logger.error(`memory-flair register error: ${err.message}`);
508
+ throw err;
509
+ }
510
+ },
511
+ };
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "memory-flair",
3
+ "kind": "memory",
4
+ "uiHints": {
5
+ "url": {
6
+ "label": "Flair URL",
7
+ "placeholder": "http://localhost:9926",
8
+ "help": "Base URL for the Flair server"
9
+ },
10
+ "agentId": {
11
+ "label": "Agent ID",
12
+ "placeholder": "flint",
13
+ "help": "TPS agent identifier (used for memory namespacing)"
14
+ },
15
+ "keyPath": {
16
+ "label": "Private Key Path",
17
+ "placeholder": "~/.tps/secrets/flair/flint-priv.key",
18
+ "help": "Path to Ed25519 private key for Flair auth",
19
+ "sensitive": true,
20
+ "advanced": true
21
+ },
22
+ "autoCapture": {
23
+ "label": "Auto-Capture",
24
+ "help": "Automatically capture important information from conversations"
25
+ },
26
+ "autoRecall": {
27
+ "label": "Auto-Recall",
28
+ "help": "Automatically inject relevant memories into context at session start"
29
+ }
30
+ },
31
+ "configSchema": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "properties": {
35
+ "url": { "type": "string" },
36
+ "agentId": { "type": "string" },
37
+ "keyPath": { "type": "string" },
38
+ "autoCapture": { "type": "boolean" },
39
+ "autoRecall": { "type": "boolean" },
40
+ "maxRecallResults": { "type": "number", "minimum": 1, "maximum": 20 },
41
+ "maxBootstrapTokens": { "type": "number", "minimum": 500, "maximum": 8000 }
42
+ },
43
+ "required": ["agentId"]
44
+ }
45
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tpsdev-ai/openclaw-flair",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw memory plugin for Flair — agent identity and semantic memory",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "openclaw.plugin.json",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "openclaw",
17
+ "openclaw-plugin",
18
+ "memory",
19
+ "flair",
20
+ "harper",
21
+ "semantic-search",
22
+ "agent-memory"
23
+ ],
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/tpsdev-ai/flair.git",
28
+ "directory": "plugins/openclaw-memory"
29
+ },
30
+ "homepage": "https://github.com/tpsdev-ai/flair/tree/main/plugins/openclaw-memory",
31
+ "peerDependencies": {
32
+ "openclaw": ">=2025.0.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "dependencies": {
38
+ "@sinclair/typebox": "^0.34.0"
39
+ }
40
+ }