@tpsdev-ai/openclaw-flair 0.1.4 → 0.3.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.
Files changed (3) hide show
  1. package/index.ts +41 -197
  2. package/key-resolver.ts +37 -0
  3. package/package.json +6 -4
package/index.ts CHANGED
@@ -13,12 +13,14 @@
13
13
  * - agent_end hook → auto-capture from conversation
14
14
  */
15
15
 
16
- import { randomUUID, createHash } from "node:crypto";
16
+ import { createHash } from "node:crypto";
17
17
  import { existsSync, readFileSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
- import { resolve, basename } from "node:path";
19
+ import { resolve } from "node:path";
20
20
  import { Type } from "@sinclair/typebox";
21
+ import { FlairClient } from "@tpsdev-ai/flair-client";
21
22
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
23
+ import { resolveAgentId } from "./key-resolver.js";
22
24
 
23
25
  // ─── Config ──────────────────────────────────────────────────────────────────
24
26
 
@@ -36,166 +38,8 @@ const DEFAULT_URL = "http://127.0.0.1:9926";
36
38
  const DEFAULT_MAX_RECALL = 5;
37
39
  const DEFAULT_MAX_BOOTSTRAP_TOKENS = 4000;
38
40
 
39
- // ─── Key Resolution (separated from network client for security scanner) ──────
40
-
41
- /**
42
- * Resolve the default Flair key path for an agent.
43
- * Checks FLAIR_KEY_DIR env var first, then standard ~/.flair/keys/ path.
44
- */
45
- function resolveDefaultKeyPath(agentId: string): string | null {
46
- const keyDirEnv = process.env.FLAIR_KEY_DIR;
47
- if (keyDirEnv) {
48
- const envPath = resolve(keyDirEnv, `${agentId}.key`);
49
- if (existsSync(envPath)) return envPath;
50
- }
51
- const standard = resolve(homedir(), ".flair", "keys", `${agentId}.key`);
52
- if (existsSync(standard)) return standard;
53
- return null;
54
- }
55
-
56
- // ─── Flair HTTP Client ────────────────────────────────────────────────────────
57
-
58
- class FlairMemoryClient {
59
- private readonly baseUrl: string;
60
- private readonly agentId: string;
61
- private readonly keyPath: string | null;
62
-
63
- constructor(config: FlairMemoryConfig) {
64
- this.baseUrl = (config.url ?? DEFAULT_URL).replace(/\/$/, "");
65
- this.agentId = config.agentId;
66
- this.keyPath = config.keyPath
67
- ? resolve(config.keyPath.replace(/^~/, homedir()))
68
- : resolveDefaultKeyPath(config.agentId);
69
- }
70
-
71
- private buildAuthHeader(method: string, path: string): Record<string, string> {
72
- if (!this.keyPath || !existsSync(this.keyPath)) return {};
73
- try {
74
- const { sign: ed25519Sign, createPrivateKey, randomUUID: rv } = require("node:crypto");
75
- // Read raw bytes — supports both:
76
- // - 32-byte binary seed (written by `flair init`)
77
- // - base64-encoded seed (legacy format)
78
- const fileBuf = readFileSync(this.keyPath);
79
- let rawBuf: Buffer;
80
- if (fileBuf.length === 32) {
81
- // Raw binary seed
82
- rawBuf = fileBuf;
83
- } else {
84
- // Try base64 decode
85
- rawBuf = Buffer.from(fileBuf.toString("utf-8").trim(), "base64");
86
- }
87
- let privateKey: ReturnType<typeof createPrivateKey>;
88
- if (rawBuf.length === 32) {
89
- // Raw Ed25519 seed — wrap in PKCS8 DER envelope
90
- const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
91
- privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, rawBuf]), format: "der", type: "pkcs8" });
92
- } else {
93
- privateKey = createPrivateKey({ key: rawBuf, format: "der", type: "pkcs8" });
94
- }
95
- const ts = Date.now().toString();
96
- const nonce = rv();
97
- const payload = `${this.agentId}:${ts}:${nonce}:${method}:${path}`;
98
- const sig = ed25519Sign(null, Buffer.from(payload), privateKey);
99
- return { Authorization: `TPS-Ed25519 ${this.agentId}:${ts}:${nonce}:${sig.toString("base64")}` };
100
- } catch {
101
- return {};
102
- }
103
- }
104
-
105
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
106
- const res = await fetch(`${this.baseUrl}${path}`, {
107
- method,
108
- headers: {
109
- "Content-Type": "application/json",
110
- ...this.buildAuthHeader(method, path),
111
- },
112
- body: body !== undefined ? JSON.stringify(body) : undefined,
113
- signal: AbortSignal.timeout(10_000),
114
- });
115
- if (!res.ok) {
116
- const text = await res.text().catch(() => "");
117
- throw new Error(`Flair ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
118
- }
119
- const text = await res.text();
120
- return text ? (JSON.parse(text) as T) : ({} as T);
121
- }
122
-
123
- async writeMemory(id: string, content: string, opts: { durability?: string; type?: string; tags?: string[]; supersedes?: string } = {}): Promise<void> {
124
- const record: Record<string, unknown> = {
125
- id,
126
- agentId: this.agentId,
127
- content,
128
- durability: opts.durability ?? "standard",
129
- type: opts.type ?? "session",
130
- createdAt: new Date().toISOString(),
131
- };
132
- if (opts.tags?.length) record.tags = opts.tags;
133
- if (opts.supersedes) record.supersedes = opts.supersedes;
134
- await this.request("PUT", `/Memory/${id}`, record);
135
- }
136
-
137
- async searchMemories(query: string, limit: number): Promise<Array<{ id: string; content: string; score: number; tags?: string[] }>> {
138
- const result = await this.request<{ results?: Array<{ id: string; content: string; score?: number; similarity?: number; memory?: { id: string; content: string; tags?: string[] } }> }>(
139
- "POST",
140
- "/SemanticSearch",
141
- { agentId: this.agentId, q: query, limit },
142
- );
143
- return (result.results ?? []).map((r) => ({
144
- id: r.id ?? r.memory?.id ?? "",
145
- content: r.content ?? r.memory?.content ?? "",
146
- score: r.score ?? r.similarity ?? 0,
147
- tags: r.memory?.tags,
148
- }));
149
- }
150
-
151
- async getMemory(id: string): Promise<{ id: string; content: string; createdAt?: string } | null> {
152
- try {
153
- return await this.request("GET", `/Memory/${id}`);
154
- } catch {
155
- return null;
156
- }
157
- }
158
-
159
- async bootstrap(opts: { days?: number } = {}): Promise<string> {
160
- try {
161
- const days = opts.days ?? 7;
162
- const since = new Date(Date.now() - days * 86_400_000).toISOString();
163
- const result = await this.request<{ context?: string; text?: string }>(
164
- "POST",
165
- "/BootstrapMemories",
166
- { agentId: this.agentId, since },
167
- );
168
- return result.context ?? result.text ?? "";
169
- } catch {
170
- return "";
171
- }
172
- }
173
-
174
- async getSoul(key: string): Promise<{ id: string; key: string; value: string; contentHash?: string } | null> {
175
- try {
176
- return await this.request("GET", `/Soul/${this.agentId}-${key}`);
177
- } catch {
178
- return null;
179
- }
180
- }
181
-
182
- async writeSoul(key: string, value: string, contentHash?: string): Promise<void> {
183
- const record: Record<string, unknown> = {
184
- id: `${this.agentId}-${key}`,
185
- agentId: this.agentId,
186
- key,
187
- value,
188
- durability: "permanent",
189
- createdAt: new Date().toISOString(),
190
- };
191
- if (contentHash) record.contentHash = contentHash;
192
- await this.request("PUT", `/Soul/${this.agentId}-${key}`, record);
193
- }
194
- }
195
-
196
41
  // ─── Workspace sync helpers ───────────────────────────────────────────────────
197
42
 
198
- /** Files to sync from workspace to Flair soul entries (spec: OPS-workspace-sync) */
199
43
  const WORKSPACE_SOUL_FILES: Record<string, string> = {
200
44
  "SOUL.md": "soul",
201
45
  "IDENTITY.md": "identity",
@@ -203,19 +47,14 @@ const WORKSPACE_SOUL_FILES: Record<string, string> = {
203
47
  "AGENTS.md": "workspace-rules",
204
48
  };
205
49
 
206
- /** Max size for a single soul entry (chars). Files larger are truncated. */
207
50
  const MAX_SOUL_VALUE = 8000;
208
51
 
209
52
  function hashContent(content: string): string {
210
53
  return createHash("sha256").update(content).digest("hex").slice(0, 16);
211
54
  }
212
55
 
213
- /**
214
- * Sync workspace files to Flair soul entries.
215
- * Only writes when content has changed (hash comparison).
216
- */
217
56
  async function syncWorkspaceToFlair(
218
- client: FlairMemoryClient,
57
+ client: FlairClient,
219
58
  agentId: string,
220
59
  logger: { info: Function; warn: Function },
221
60
  ): Promise<number> {
@@ -233,12 +72,10 @@ async function syncWorkspaceToFlair(
233
72
  if (content.length > MAX_SOUL_VALUE) content = content.slice(0, MAX_SOUL_VALUE) + "\n…(truncated)";
234
73
 
235
74
  const newHash = hashContent(content);
75
+ const existing = await client.soul.get(soulKey);
76
+ if ((existing as any)?.contentHash === newHash) continue;
236
77
 
237
- // Check existing entry's hash
238
- const existing = await client.getSoul(soulKey);
239
- if (existing?.contentHash === newHash) continue; // unchanged
240
-
241
- await client.writeSoul(soulKey, content, newHash);
78
+ await client.soul.set(soulKey, content);
242
79
  synced++;
243
80
  logger.info(`openclaw-flair: synced ${filename} → soul:${soulKey} (hash=${newHash})`);
244
81
  } catch (err: any) {
@@ -274,27 +111,35 @@ export default {
274
111
  const cfg = (api.pluginConfig ?? {}) as FlairMemoryConfig;
275
112
  const isAutoMode = !cfg.agentId || cfg.agentId === "auto";
276
113
 
277
- // Client pool: one client per agentId, created lazily
278
- const clientPool = new Map<string, FlairMemoryClient>();
279
-
280
- function getClient(agentId?: string): FlairMemoryClient {
281
- const id = agentId || cfg.agentId;
282
- if (!id || id === "auto") throw new Error("no agentId available — set agentId in plugin config or ensure OpenClaw provides it via session context");
114
+ // Client pool: one FlairClient per agentId, created lazily
115
+ const clientPool = new Map<string, FlairClient>();
116
+
117
+ // Resolve fallback agentId once at registration time
118
+ const fallbackAgentId = resolveAgentId();
119
+
120
+ function getClient(agentId?: string): FlairClient {
121
+ const id = agentId || cfg.agentId || fallbackAgentId;
122
+ if (!id || id === "auto") throw new Error("no agentId available — set agentId in plugin config, FLAIR_AGENT_ID env var, or ensure OpenClaw provides it via session context");
283
123
  let client = clientPool.get(id);
284
124
  if (!client) {
285
- client = new FlairMemoryClient({ ...cfg, agentId: id });
125
+ client = new FlairClient({
126
+ url: cfg.url ?? DEFAULT_URL,
127
+ agentId: id,
128
+ keyPath: cfg.keyPath,
129
+ });
286
130
  clientPool.set(id, client);
287
131
  }
288
132
  return client;
289
133
  }
290
134
 
291
- // For non-auto mode, pre-create the client
292
135
  if (!isAutoMode) {
293
136
  getClient(cfg.agentId);
294
137
  }
295
138
 
296
139
  if (!isAutoMode) {
297
140
  api.logger.info("openclaw-flair: client created");
141
+ } else if (fallbackAgentId) {
142
+ api.logger.info(`openclaw-flair: auto mode — fallback agentId="${fallbackAgentId}" (from config/env)`);
298
143
  } else {
299
144
  api.logger.info("openclaw-flair: auto mode — agentId will be resolved from session context");
300
145
  }
@@ -302,14 +147,12 @@ export default {
302
147
  const autoCapture = cfg.autoCapture ?? true;
303
148
  const autoRecall = cfg.autoRecall ?? true;
304
149
 
305
- // Track current agent per-session via hooks (tools don't get agentId in execute)
306
- let currentAgentId: string | undefined = isAutoMode ? undefined : cfg.agentId;
307
-
150
+ let currentAgentId: string | undefined = isAutoMode ? fallbackAgentId ?? undefined : cfg.agentId;
151
+
308
152
  api.on("before_agent_start", async (event: any, ctx: any) => {
309
153
  const eventAgentId = ctx?.agentId || (event as any).agentId;
310
154
  if (eventAgentId) currentAgentId = eventAgentId;
311
155
 
312
- // Sync workspace files → Flair soul entries (hash-based, only on change)
313
156
  if (eventAgentId) {
314
157
  try {
315
158
  const client = getClient(eventAgentId);
@@ -320,9 +163,8 @@ export default {
320
163
  }
321
164
  }
322
165
  });
323
-
324
- // Helper to get client using tracked agentId
325
- function getCurrentClient(): FlairMemoryClient {
166
+
167
+ function getCurrentClient(): FlairClient {
326
168
  return getClient(currentAgentId);
327
169
  }
328
170
 
@@ -345,7 +187,7 @@ export default {
345
187
  const { query, limit = maxRecall } = params as { query: string; limit?: number };
346
188
  try {
347
189
  const client = getCurrentClient();
348
- const results = await client.searchMemories(query, limit);
190
+ const results = await client.memory.search(query, { limit });
349
191
  if (results.length === 0) {
350
192
  return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
351
193
  }
@@ -398,10 +240,14 @@ export default {
398
240
  durability?: string; type?: string; supersedes?: string;
399
241
  };
400
242
  try {
401
- const agentId = currentAgentId || cfg.agentId;
402
243
  const client = getCurrentClient();
403
- const memId = `${agentId}-${Date.now()}`;
404
- await client.writeMemory(memId, text, { tags, durability, type, supersedes });
244
+ const memId = `${client.agentId}-${Date.now()}`;
245
+ await client.memory.write(text, {
246
+ id: memId,
247
+ tags,
248
+ durability: durability as any,
249
+ type: type as any,
250
+ });
405
251
  return {
406
252
  content: [{ type: "text", text: `Memory stored (id: ${memId})` }],
407
253
  details: { id: memId },
@@ -429,7 +275,7 @@ export default {
429
275
  const { id } = params as { id: string };
430
276
  try {
431
277
  const client = getCurrentClient();
432
- const mem = await client.getMemory(id);
278
+ const mem = await client.memory.get(id);
433
279
  if (!mem) return { content: [{ type: "text", text: `Memory ${id} not found.` }], details: {} };
434
280
  return {
435
281
  content: [{ type: "text", text: mem.content }],
@@ -448,10 +294,10 @@ export default {
448
294
  if (autoRecall) {
449
295
  api.on("before_agent_start", async (event: any, ctx: any) => {
450
296
  try {
451
- // Ensure agentId is set (in case this fires before the tracking hook)
452
297
  if (ctx?.agentId && !currentAgentId) currentAgentId = ctx.agentId;
453
298
  const client = getCurrentClient();
454
- const context = await client.bootstrap({ days: 7 });
299
+ const result = await client.bootstrap({ maxTokens: cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS });
300
+ const context = result.context;
455
301
  if (context && typeof context === "string" && context.trim().length > 0) {
456
302
  const truncated = context.slice(0, (cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS) * 4);
457
303
  event.injectContext?.(`\n## Memory Context (from Flair)\n\n${truncated}\n`);
@@ -468,7 +314,6 @@ export default {
468
314
  if (autoCapture) {
469
315
  api.on("agent_end", async (event) => {
470
316
  try {
471
- const agentId = currentAgentId || cfg.agentId;
472
317
  const client = getCurrentClient();
473
318
  const messages = (event.messages ?? []) as Array<{ role: string; content?: string }>;
474
319
  let stored = 0;
@@ -477,10 +322,9 @@ export default {
477
322
  const text = typeof msg.content === "string" ? msg.content : "";
478
323
  if (!text || !shouldCapture(text)) continue;
479
324
  const excerpt = excerptForCapture(text);
480
- const memId = `${agentId}-${Date.now()}-${stored}`;
481
- await client.writeMemory(memId, excerpt, { type: "session", tags: ["auto-captured"] });
325
+ await client.memory.write(excerpt, { type: "session", tags: ["auto-captured"] });
482
326
  stored++;
483
- if (stored >= 3) break; // cap at 3 per session
327
+ if (stored >= 3) break;
484
328
  }
485
329
  if (stored > 0) api.logger.info(`openclaw-flair: auto-captured ${stored} memories`);
486
330
  } catch (err: any) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * OpenClaw-specific identity resolution.
3
+ *
4
+ * Key path and private key loading are now handled by @tpsdev-ai/flair-client.
5
+ * This file only contains resolveAgentId() which reads OpenClaw config — something
6
+ * the generic flair-client shouldn't know about.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { resolve } from "node:path";
12
+
13
+ /**
14
+ * Resolve agent ID when not explicitly configured.
15
+ * Priority: FLAIR_AGENT_ID env > OpenClaw config file > null
16
+ */
17
+ export function resolveAgentId(): string | null {
18
+ // 1. Explicit env var
19
+ const envId = process.env.FLAIR_AGENT_ID;
20
+ if (envId) return envId;
21
+
22
+ // 2. Read from OpenClaw config — first agent name
23
+ try {
24
+ const configPath = resolve(homedir(), ".openclaw", "openclaw.json");
25
+ if (!existsSync(configPath)) return null;
26
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
27
+ const agents = config?.agents?.list;
28
+ if (Array.isArray(agents) && agents.length > 0) {
29
+ const name = agents[0]?.name;
30
+ if (typeof name === "string" && name) return name.toLowerCase();
31
+ }
32
+ } catch {
33
+ // Config unreadable — fall through
34
+ }
35
+
36
+ return null;
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/openclaw-flair",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw memory plugin for Flair — agent identity and semantic memory",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "index.ts",
12
+ "key-resolver.ts",
12
13
  "openclaw.plugin.json",
13
14
  "README.md"
14
15
  ],
@@ -25,9 +26,9 @@
25
26
  "repository": {
26
27
  "type": "git",
27
28
  "url": "https://github.com/tpsdev-ai/flair.git",
28
- "directory": "plugins/openclaw-memory"
29
+ "directory": "plugins/openclaw-flair"
29
30
  },
30
- "homepage": "https://github.com/tpsdev-ai/flair/tree/main/plugins/openclaw-memory",
31
+ "homepage": "https://github.com/tpsdev-ai/flair/tree/main/plugins/openclaw-flair",
31
32
  "openclaw": {
32
33
  "extensions": ["./index.ts"]
33
34
  },
@@ -38,6 +39,7 @@
38
39
  "node": ">=22"
39
40
  },
40
41
  "dependencies": {
41
- "@sinclair/typebox": "^0.34.0"
42
+ "@sinclair/typebox": "^0.34.0",
43
+ "@tpsdev-ai/flair-client": "^0.1.0"
42
44
  }
43
45
  }