@tpsdev-ai/openclaw-flair 0.2.0 → 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 +32 -157
  2. package/key-resolver.ts +5 -52
  3. package/package.json +3 -2
package/index.ts CHANGED
@@ -13,13 +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
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";
22
- import { resolveKeyPath, loadPrivateKey, resolveAgentId } from "./key-resolver.js";
23
+ import { resolveAgentId } from "./key-resolver.js";
23
24
 
24
25
  // ─── Config ──────────────────────────────────────────────────────────────────
25
26
 
@@ -37,129 +38,8 @@ const DEFAULT_URL = "http://127.0.0.1:9926";
37
38
  const DEFAULT_MAX_RECALL = 5;
38
39
  const DEFAULT_MAX_BOOTSTRAP_TOKENS = 4000;
39
40
 
40
- // ─── Flair HTTP Client ────────────────────────────────────────────────────────
41
-
42
- class FlairMemoryClient {
43
- private readonly baseUrl: string;
44
- private readonly agentId: string;
45
- private readonly keyPath: string | null;
46
-
47
- constructor(config: FlairMemoryConfig) {
48
- this.baseUrl = (config.url ?? DEFAULT_URL).replace(/\/$/, "");
49
- this.agentId = config.agentId;
50
- this.keyPath = resolveKeyPath(config.agentId, config.keyPath);
51
- }
52
-
53
- private buildAuthHeader(method: string, path: string): Record<string, string> {
54
- if (!this.keyPath) return {};
55
- try {
56
- const { sign: ed25519Sign, randomUUID: rv } = require("node:crypto");
57
- const privateKey = loadPrivateKey(this.keyPath);
58
- if (!privateKey) return {};
59
- const ts = Date.now().toString();
60
- const nonce = rv();
61
- const payload = `${this.agentId}:${ts}:${nonce}:${method}:${path}`;
62
- const sig = ed25519Sign(null, Buffer.from(payload), privateKey);
63
- return { Authorization: `TPS-Ed25519 ${this.agentId}:${ts}:${nonce}:${sig.toString("base64")}` };
64
- } catch {
65
- return {};
66
- }
67
- }
68
-
69
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
70
- const res = await fetch(`${this.baseUrl}${path}`, {
71
- method,
72
- headers: {
73
- "Content-Type": "application/json",
74
- ...this.buildAuthHeader(method, path),
75
- },
76
- body: body !== undefined ? JSON.stringify(body) : undefined,
77
- signal: AbortSignal.timeout(10_000),
78
- });
79
- if (!res.ok) {
80
- const text = await res.text().catch(() => "");
81
- throw new Error(`Flair ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
82
- }
83
- const text = await res.text();
84
- return text ? (JSON.parse(text) as T) : ({} as T);
85
- }
86
-
87
- async writeMemory(id: string, content: string, opts: { durability?: string; type?: string; tags?: string[]; supersedes?: string } = {}): Promise<void> {
88
- const record: Record<string, unknown> = {
89
- id,
90
- agentId: this.agentId,
91
- content,
92
- durability: opts.durability ?? "standard",
93
- type: opts.type ?? "session",
94
- createdAt: new Date().toISOString(),
95
- };
96
- if (opts.tags?.length) record.tags = opts.tags;
97
- if (opts.supersedes) record.supersedes = opts.supersedes;
98
- await this.request("PUT", `/Memory/${id}`, record);
99
- }
100
-
101
- async searchMemories(query: string, limit: number): Promise<Array<{ id: string; content: string; score: number; tags?: string[] }>> {
102
- const result = await this.request<{ results?: Array<{ id: string; content: string; score?: number; similarity?: number; memory?: { id: string; content: string; tags?: string[] } }> }>(
103
- "POST",
104
- "/SemanticSearch",
105
- { agentId: this.agentId, q: query, limit },
106
- );
107
- return (result.results ?? []).map((r) => ({
108
- id: r.id ?? r.memory?.id ?? "",
109
- content: r.content ?? r.memory?.content ?? "",
110
- score: r.score ?? r.similarity ?? 0,
111
- tags: r.memory?.tags,
112
- }));
113
- }
114
-
115
- async getMemory(id: string): Promise<{ id: string; content: string; createdAt?: string } | null> {
116
- try {
117
- return await this.request("GET", `/Memory/${id}`);
118
- } catch {
119
- return null;
120
- }
121
- }
122
-
123
- async bootstrap(opts: { days?: number } = {}): Promise<string> {
124
- try {
125
- const days = opts.days ?? 7;
126
- const since = new Date(Date.now() - days * 86_400_000).toISOString();
127
- const result = await this.request<{ context?: string; text?: string }>(
128
- "POST",
129
- "/BootstrapMemories",
130
- { agentId: this.agentId, since },
131
- );
132
- return result.context ?? result.text ?? "";
133
- } catch {
134
- return "";
135
- }
136
- }
137
-
138
- async getSoul(key: string): Promise<{ id: string; key: string; value: string; contentHash?: string } | null> {
139
- try {
140
- return await this.request("GET", `/Soul/${this.agentId}-${key}`);
141
- } catch {
142
- return null;
143
- }
144
- }
145
-
146
- async writeSoul(key: string, value: string, contentHash?: string): Promise<void> {
147
- const record: Record<string, unknown> = {
148
- id: `${this.agentId}-${key}`,
149
- agentId: this.agentId,
150
- key,
151
- value,
152
- durability: "permanent",
153
- createdAt: new Date().toISOString(),
154
- };
155
- if (contentHash) record.contentHash = contentHash;
156
- await this.request("PUT", `/Soul/${this.agentId}-${key}`, record);
157
- }
158
- }
159
-
160
41
  // ─── Workspace sync helpers ───────────────────────────────────────────────────
161
42
 
162
- /** Files to sync from workspace to Flair soul entries (spec: OPS-workspace-sync) */
163
43
  const WORKSPACE_SOUL_FILES: Record<string, string> = {
164
44
  "SOUL.md": "soul",
165
45
  "IDENTITY.md": "identity",
@@ -167,19 +47,14 @@ const WORKSPACE_SOUL_FILES: Record<string, string> = {
167
47
  "AGENTS.md": "workspace-rules",
168
48
  };
169
49
 
170
- /** Max size for a single soul entry (chars). Files larger are truncated. */
171
50
  const MAX_SOUL_VALUE = 8000;
172
51
 
173
52
  function hashContent(content: string): string {
174
53
  return createHash("sha256").update(content).digest("hex").slice(0, 16);
175
54
  }
176
55
 
177
- /**
178
- * Sync workspace files to Flair soul entries.
179
- * Only writes when content has changed (hash comparison).
180
- */
181
56
  async function syncWorkspaceToFlair(
182
- client: FlairMemoryClient,
57
+ client: FlairClient,
183
58
  agentId: string,
184
59
  logger: { info: Function; warn: Function },
185
60
  ): Promise<number> {
@@ -197,12 +72,10 @@ async function syncWorkspaceToFlair(
197
72
  if (content.length > MAX_SOUL_VALUE) content = content.slice(0, MAX_SOUL_VALUE) + "\n…(truncated)";
198
73
 
199
74
  const newHash = hashContent(content);
75
+ const existing = await client.soul.get(soulKey);
76
+ if ((existing as any)?.contentHash === newHash) continue;
200
77
 
201
- // Check existing entry's hash
202
- const existing = await client.getSoul(soulKey);
203
- if (existing?.contentHash === newHash) continue; // unchanged
204
-
205
- await client.writeSoul(soulKey, content, newHash);
78
+ await client.soul.set(soulKey, content);
206
79
  synced++;
207
80
  logger.info(`openclaw-flair: synced ${filename} → soul:${soulKey} (hash=${newHash})`);
208
81
  } catch (err: any) {
@@ -238,24 +111,27 @@ export default {
238
111
  const cfg = (api.pluginConfig ?? {}) as FlairMemoryConfig;
239
112
  const isAutoMode = !cfg.agentId || cfg.agentId === "auto";
240
113
 
241
- // Client pool: one client per agentId, created lazily
242
- const clientPool = new Map<string, FlairMemoryClient>();
243
-
114
+ // Client pool: one FlairClient per agentId, created lazily
115
+ const clientPool = new Map<string, FlairClient>();
116
+
244
117
  // Resolve fallback agentId once at registration time
245
118
  const fallbackAgentId = resolveAgentId();
246
119
 
247
- function getClient(agentId?: string): FlairMemoryClient {
120
+ function getClient(agentId?: string): FlairClient {
248
121
  const id = agentId || cfg.agentId || fallbackAgentId;
249
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");
250
123
  let client = clientPool.get(id);
251
124
  if (!client) {
252
- client = new FlairMemoryClient({ ...cfg, agentId: id });
125
+ client = new FlairClient({
126
+ url: cfg.url ?? DEFAULT_URL,
127
+ agentId: id,
128
+ keyPath: cfg.keyPath,
129
+ });
253
130
  clientPool.set(id, client);
254
131
  }
255
132
  return client;
256
133
  }
257
134
 
258
- // For non-auto mode, pre-create the client
259
135
  if (!isAutoMode) {
260
136
  getClient(cfg.agentId);
261
137
  }
@@ -271,14 +147,12 @@ export default {
271
147
  const autoCapture = cfg.autoCapture ?? true;
272
148
  const autoRecall = cfg.autoRecall ?? true;
273
149
 
274
- // Track current agent per-session via hooks (tools don't get agentId in execute)
275
150
  let currentAgentId: string | undefined = isAutoMode ? fallbackAgentId ?? undefined : cfg.agentId;
276
-
151
+
277
152
  api.on("before_agent_start", async (event: any, ctx: any) => {
278
153
  const eventAgentId = ctx?.agentId || (event as any).agentId;
279
154
  if (eventAgentId) currentAgentId = eventAgentId;
280
155
 
281
- // Sync workspace files → Flair soul entries (hash-based, only on change)
282
156
  if (eventAgentId) {
283
157
  try {
284
158
  const client = getClient(eventAgentId);
@@ -289,9 +163,8 @@ export default {
289
163
  }
290
164
  }
291
165
  });
292
-
293
- // Helper to get client using tracked agentId
294
- function getCurrentClient(): FlairMemoryClient {
166
+
167
+ function getCurrentClient(): FlairClient {
295
168
  return getClient(currentAgentId);
296
169
  }
297
170
 
@@ -314,7 +187,7 @@ export default {
314
187
  const { query, limit = maxRecall } = params as { query: string; limit?: number };
315
188
  try {
316
189
  const client = getCurrentClient();
317
- const results = await client.searchMemories(query, limit);
190
+ const results = await client.memory.search(query, { limit });
318
191
  if (results.length === 0) {
319
192
  return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
320
193
  }
@@ -367,10 +240,14 @@ export default {
367
240
  durability?: string; type?: string; supersedes?: string;
368
241
  };
369
242
  try {
370
- const agentId = currentAgentId || cfg.agentId;
371
243
  const client = getCurrentClient();
372
- const memId = `${agentId}-${Date.now()}`;
373
- 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
+ });
374
251
  return {
375
252
  content: [{ type: "text", text: `Memory stored (id: ${memId})` }],
376
253
  details: { id: memId },
@@ -398,7 +275,7 @@ export default {
398
275
  const { id } = params as { id: string };
399
276
  try {
400
277
  const client = getCurrentClient();
401
- const mem = await client.getMemory(id);
278
+ const mem = await client.memory.get(id);
402
279
  if (!mem) return { content: [{ type: "text", text: `Memory ${id} not found.` }], details: {} };
403
280
  return {
404
281
  content: [{ type: "text", text: mem.content }],
@@ -417,10 +294,10 @@ export default {
417
294
  if (autoRecall) {
418
295
  api.on("before_agent_start", async (event: any, ctx: any) => {
419
296
  try {
420
- // Ensure agentId is set (in case this fires before the tracking hook)
421
297
  if (ctx?.agentId && !currentAgentId) currentAgentId = ctx.agentId;
422
298
  const client = getCurrentClient();
423
- 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;
424
301
  if (context && typeof context === "string" && context.trim().length > 0) {
425
302
  const truncated = context.slice(0, (cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS) * 4);
426
303
  event.injectContext?.(`\n## Memory Context (from Flair)\n\n${truncated}\n`);
@@ -437,7 +314,6 @@ export default {
437
314
  if (autoCapture) {
438
315
  api.on("agent_end", async (event) => {
439
316
  try {
440
- const agentId = currentAgentId || cfg.agentId;
441
317
  const client = getCurrentClient();
442
318
  const messages = (event.messages ?? []) as Array<{ role: string; content?: string }>;
443
319
  let stored = 0;
@@ -446,10 +322,9 @@ export default {
446
322
  const text = typeof msg.content === "string" ? msg.content : "";
447
323
  if (!text || !shouldCapture(text)) continue;
448
324
  const excerpt = excerptForCapture(text);
449
- const memId = `${agentId}-${Date.now()}-${stored}`;
450
- await client.writeMemory(memId, excerpt, { type: "session", tags: ["auto-captured"] });
325
+ await client.memory.write(excerpt, { type: "session", tags: ["auto-captured"] });
451
326
  stored++;
452
- if (stored >= 3) break; // cap at 3 per session
327
+ if (stored >= 3) break;
453
328
  }
454
329
  if (stored > 0) api.logger.info(`openclaw-flair: auto-captured ${stored} memories`);
455
330
  } catch (err: any) {
package/key-resolver.ts CHANGED
@@ -1,61 +1,15 @@
1
1
  /**
2
- * Key and identity resolution for Flair authentication.
3
- * Separated from the HTTP client to avoid security scanner false positives
4
- * (env var access + network send in the same file).
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.
5
7
  */
6
8
 
7
9
  import { existsSync, readFileSync } from "node:fs";
8
10
  import { homedir } from "node:os";
9
11
  import { resolve } from "node:path";
10
12
 
11
- /**
12
- * Resolve the Flair key path for an agent.
13
- * Priority: explicit keyPath > FLAIR_KEY_DIR env > ~/.flair/keys/<agent>.key
14
- */
15
- export function resolveKeyPath(agentId: string, explicitPath?: string): string | null {
16
- if (explicitPath) {
17
- return resolve(explicitPath.replace(/^~/, homedir()));
18
- }
19
-
20
- // 1. FLAIR_KEY_DIR env var
21
- const keyDirEnv = process.env.FLAIR_KEY_DIR;
22
- if (keyDirEnv) {
23
- const envPath = resolve(keyDirEnv, `${agentId}.key`);
24
- if (existsSync(envPath)) return envPath;
25
- }
26
-
27
- // 2. ~/.flair/keys/<agent>.key (standard — use `flair agent add` to generate)
28
- const standard = resolve(homedir(), ".flair", "keys", `${agentId}.key`);
29
- if (existsSync(standard)) return standard;
30
-
31
- return null;
32
- }
33
-
34
- /**
35
- * Load and parse an Ed25519 private key from a key file.
36
- * Supports both raw 32-byte binary seeds and base64-encoded PKCS8 DER.
37
- */
38
- export function loadPrivateKey(keyPath: string): ReturnType<typeof import("node:crypto").createPrivateKey> | null {
39
- if (!existsSync(keyPath)) return null;
40
- try {
41
- const { createPrivateKey } = require("node:crypto");
42
- const fileBuf = readFileSync(keyPath);
43
- let rawBuf: Buffer;
44
- if (fileBuf.length === 32) {
45
- rawBuf = fileBuf;
46
- } else {
47
- rawBuf = Buffer.from(fileBuf.toString("utf-8").trim(), "base64");
48
- }
49
- if (rawBuf.length === 32) {
50
- const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
51
- return createPrivateKey({ key: Buffer.concat([pkcs8Header, rawBuf]), format: "der", type: "pkcs8" });
52
- }
53
- return createPrivateKey({ key: rawBuf, format: "der", type: "pkcs8" });
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
13
  /**
60
14
  * Resolve agent ID when not explicitly configured.
61
15
  * Priority: FLAIR_AGENT_ID env > OpenClaw config file > null
@@ -70,7 +24,6 @@ export function resolveAgentId(): string | null {
70
24
  const configPath = resolve(homedir(), ".openclaw", "openclaw.json");
71
25
  if (!existsSync(configPath)) return null;
72
26
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
73
- // agents.list[].name is the standard OpenClaw agent config
74
27
  const agents = config?.agents?.list;
75
28
  if (Array.isArray(agents) && agents.length > 0) {
76
29
  const name = agents[0]?.name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/openclaw-flair",
3
- "version": "0.2.0",
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",
@@ -39,6 +39,7 @@
39
39
  "node": ">=22"
40
40
  },
41
41
  "dependencies": {
42
- "@sinclair/typebox": "^0.34.0"
42
+ "@sinclair/typebox": "^0.34.0",
43
+ "@tpsdev-ai/flair-client": "^0.1.0"
43
44
  }
44
45
  }