@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.
- package/index.ts +32 -157
- package/key-resolver.ts +5 -52
- 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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
242
|
-
const clientPool = new Map<string,
|
|
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):
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
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
|
}
|