@tpsdev-ai/openclaw-flair 0.2.0 → 0.4.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 +75 -164
  2. package/key-resolver.ts +5 -52
  3. package/package.json +8 -5
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) {
@@ -214,13 +87,19 @@ async function syncWorkspaceToFlair(
214
87
 
215
88
  // ─── Auto-capture helpers ─────────────────────────────────────────────────────
216
89
 
90
+ // Auto-capture triggers — conservative patterns that indicate genuinely
91
+ // important context, not casual conversation. The LLM has memory_store
92
+ // for explicit saves; auto-capture is a safety net for things it misses.
217
93
  const CAPTURE_TRIGGERS = [
218
- /\b(remember|note that|important:|keep in mind|don't forget)\b/i,
219
- /\b(preference|prefer|always|never|my name is|i am|i'm)\b/i,
220
- /\b(decided|agreed|confirmed|finalized)\b/i,
94
+ /\b(remember this|note for future|important lesson|key decision|for the record)\b/i,
95
+ /\b(my name is|call me|i go by)\b/i,
96
+ /\b(we decided|final decision|agreed to|commitment:)\b/i,
221
97
  ];
222
98
 
99
+ const MIN_CAPTURE_LENGTH = 30; // skip very short messages
100
+
223
101
  function shouldCapture(text: string): boolean {
102
+ if (text.length < MIN_CAPTURE_LENGTH) return false;
224
103
  return CAPTURE_TRIGGERS.some((re) => re.test(text));
225
104
  }
226
105
 
@@ -238,24 +117,27 @@ export default {
238
117
  const cfg = (api.pluginConfig ?? {}) as FlairMemoryConfig;
239
118
  const isAutoMode = !cfg.agentId || cfg.agentId === "auto";
240
119
 
241
- // Client pool: one client per agentId, created lazily
242
- const clientPool = new Map<string, FlairMemoryClient>();
243
-
120
+ // Client pool: one FlairClient per agentId, created lazily
121
+ const clientPool = new Map<string, FlairClient>();
122
+
244
123
  // Resolve fallback agentId once at registration time
245
124
  const fallbackAgentId = resolveAgentId();
246
125
 
247
- function getClient(agentId?: string): FlairMemoryClient {
126
+ function getClient(agentId?: string): FlairClient {
248
127
  const id = agentId || cfg.agentId || fallbackAgentId;
249
128
  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
129
  let client = clientPool.get(id);
251
130
  if (!client) {
252
- client = new FlairMemoryClient({ ...cfg, agentId: id });
131
+ client = new FlairClient({
132
+ url: cfg.url ?? DEFAULT_URL,
133
+ agentId: id,
134
+ keyPath: cfg.keyPath,
135
+ });
253
136
  clientPool.set(id, client);
254
137
  }
255
138
  return client;
256
139
  }
257
140
 
258
- // For non-auto mode, pre-create the client
259
141
  if (!isAutoMode) {
260
142
  getClient(cfg.agentId);
261
143
  }
@@ -268,17 +150,23 @@ export default {
268
150
  api.logger.info("openclaw-flair: auto mode — agentId will be resolved from session context");
269
151
  }
270
152
  const maxRecall = cfg.maxRecallResults ?? DEFAULT_MAX_RECALL;
271
- const autoCapture = cfg.autoCapture ?? true;
153
+ const autoCapture = cfg.autoCapture ?? false; // opt-in — trust the LLM to use memory_store
272
154
  const autoRecall = cfg.autoRecall ?? true;
273
155
 
274
- // Track current agent per-session via hooks (tools don't get agentId in execute)
156
+ // Per-session agentId set from config, env, or session context.
157
+ // IMPORTANT: Only update from before_agent_start if it matches our configured agent,
158
+ // NOT from other agents' cron jobs sharing the same gateway.
275
159
  let currentAgentId: string | undefined = isAutoMode ? fallbackAgentId ?? undefined : cfg.agentId;
276
-
160
+ const configuredAgentId = cfg.agentId && cfg.agentId !== "auto" ? cfg.agentId : fallbackAgentId;
161
+
277
162
  api.on("before_agent_start", async (event: any, ctx: any) => {
278
163
  const eventAgentId = ctx?.agentId || (event as any).agentId;
279
- if (eventAgentId) currentAgentId = eventAgentId;
164
+ // Only adopt the session agentId if we don't already have one configured,
165
+ // or if it matches our configured agent. Don't let kern's cron overwrite flint's agentId.
166
+ if (eventAgentId && (!configuredAgentId || eventAgentId === configuredAgentId)) {
167
+ currentAgentId = eventAgentId;
168
+ }
280
169
 
281
- // Sync workspace files → Flair soul entries (hash-based, only on change)
282
170
  if (eventAgentId) {
283
171
  try {
284
172
  const client = getClient(eventAgentId);
@@ -289,9 +177,8 @@ export default {
289
177
  }
290
178
  }
291
179
  });
292
-
293
- // Helper to get client using tracked agentId
294
- function getCurrentClient(): FlairMemoryClient {
180
+
181
+ function getCurrentClient(): FlairClient {
295
182
  return getClient(currentAgentId);
296
183
  }
297
184
 
@@ -314,7 +201,7 @@ export default {
314
201
  const { query, limit = maxRecall } = params as { query: string; limit?: number };
315
202
  try {
316
203
  const client = getCurrentClient();
317
- const results = await client.searchMemories(query, limit);
204
+ const results = await client.memory.search(query, { limit });
318
205
  if (results.length === 0) {
319
206
  return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
320
207
  }
@@ -367,13 +254,39 @@ export default {
367
254
  durability?: string; type?: string; supersedes?: string;
368
255
  };
369
256
  try {
370
- const agentId = currentAgentId || cfg.agentId;
371
257
  const client = getCurrentClient();
372
- const memId = `${agentId}-${Date.now()}`;
373
- await client.writeMemory(memId, text, { tags, durability, type, supersedes });
258
+ const memId = `${client.agentId}-${Date.now()}`;
259
+ // If superseding an old memory, archive it
260
+ if (supersedes) {
261
+ try {
262
+ const old = await client.memory.get(supersedes);
263
+ if (old) {
264
+ await client.request("PUT", `/Memory/${supersedes}`, {
265
+ ...old,
266
+ archived: true,
267
+ archivedAt: new Date().toISOString(),
268
+ supersededBy: memId,
269
+ });
270
+ }
271
+ } catch {
272
+ // Old memory not found — continue with the write
273
+ }
274
+ }
275
+
276
+ const result = await client.memory.write(text, {
277
+ id: memId,
278
+ tags,
279
+ durability: durability as any,
280
+ type: type as any,
281
+ dedup: !supersedes, // skip dedup when explicitly superseding
282
+ dedupThreshold: 0.7,
283
+ });
284
+ const wasDeduped = result.id !== memId;
374
285
  return {
375
- content: [{ type: "text", text: `Memory stored (id: ${memId})` }],
376
- details: { id: memId },
286
+ content: [{ type: "text", text: wasDeduped
287
+ ? `Similar memory already exists (id: ${result.id}): ${result.content?.slice(0, 200)}`
288
+ : `Memory stored (id: ${memId})` }],
289
+ details: { id: result.id, deduplicated: wasDeduped },
377
290
  };
378
291
  } catch (err: any) {
379
292
  api.logger.warn(`openclaw-flair: store failed: ${err.message}`);
@@ -398,7 +311,7 @@ export default {
398
311
  const { id } = params as { id: string };
399
312
  try {
400
313
  const client = getCurrentClient();
401
- const mem = await client.getMemory(id);
314
+ const mem = await client.memory.get(id);
402
315
  if (!mem) return { content: [{ type: "text", text: `Memory ${id} not found.` }], details: {} };
403
316
  return {
404
317
  content: [{ type: "text", text: mem.content }],
@@ -417,10 +330,10 @@ export default {
417
330
  if (autoRecall) {
418
331
  api.on("before_agent_start", async (event: any, ctx: any) => {
419
332
  try {
420
- // Ensure agentId is set (in case this fires before the tracking hook)
421
333
  if (ctx?.agentId && !currentAgentId) currentAgentId = ctx.agentId;
422
334
  const client = getCurrentClient();
423
- const context = await client.bootstrap({ days: 7 });
335
+ const result = await client.bootstrap({ maxTokens: cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS });
336
+ const context = result.context;
424
337
  if (context && typeof context === "string" && context.trim().length > 0) {
425
338
  const truncated = context.slice(0, (cfg.maxBootstrapTokens ?? DEFAULT_MAX_BOOTSTRAP_TOKENS) * 4);
426
339
  event.injectContext?.(`\n## Memory Context (from Flair)\n\n${truncated}\n`);
@@ -437,7 +350,6 @@ export default {
437
350
  if (autoCapture) {
438
351
  api.on("agent_end", async (event) => {
439
352
  try {
440
- const agentId = currentAgentId || cfg.agentId;
441
353
  const client = getCurrentClient();
442
354
  const messages = (event.messages ?? []) as Array<{ role: string; content?: string }>;
443
355
  let stored = 0;
@@ -446,10 +358,9 @@ export default {
446
358
  const text = typeof msg.content === "string" ? msg.content : "";
447
359
  if (!text || !shouldCapture(text)) continue;
448
360
  const excerpt = excerptForCapture(text);
449
- const memId = `${agentId}-${Date.now()}-${stored}`;
450
- await client.writeMemory(memId, excerpt, { type: "session", tags: ["auto-captured"] });
361
+ await client.memory.write(excerpt, { type: "session", tags: ["auto-captured"] });
451
362
  stored++;
452
- if (stored >= 3) break; // cap at 3 per session
363
+ if (stored >= 3) break;
453
364
  }
454
365
  if (stored > 0) api.logger.info(`openclaw-flair: auto-captured ${stored} memories`);
455
366
  } 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/openclaw-flair",
3
- "version": "0.2.0",
4
- "description": "OpenClaw memory plugin for Flair agent identity and semantic memory",
3
+ "version": "0.4.0",
4
+ "description": "OpenClaw memory plugin for Flair \u2014 agent identity and semantic memory",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "exports": {
@@ -30,7 +30,9 @@
30
30
  },
31
31
  "homepage": "https://github.com/tpsdev-ai/flair/tree/main/plugins/openclaw-flair",
32
32
  "openclaw": {
33
- "extensions": ["./index.ts"]
33
+ "extensions": [
34
+ "./index.ts"
35
+ ]
34
36
  },
35
37
  "peerDependencies": {
36
38
  "openclaw": ">=2026.3.7"
@@ -39,6 +41,7 @@
39
41
  "node": ">=22"
40
42
  },
41
43
  "dependencies": {
42
- "@sinclair/typebox": "^0.34.0"
44
+ "@sinclair/typebox": "^0.34.0",
45
+ "@tpsdev-ai/flair-client": "^0.2.0"
43
46
  }
44
- }
47
+ }