@tpsdev-ai/flair 0.3.17 → 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.
- package/README.md +49 -33
- package/config.yaml +4 -2
- package/dist/cli.js +455 -20
- package/dist/resources/Memory.js +52 -3
- package/dist/resources/MemoryBootstrap.js +7 -1
- package/dist/resources/SemanticSearch.js +129 -56
- package/dist/resources/auth-middleware.js +37 -10
- package/dist/resources/content-safety.js +62 -0
- package/dist/resources/embeddings-provider.js +86 -7
- package/dist/resources/rate-limiter.js +118 -0
- package/package.json +2 -3
- package/resources/A2AAdapter.ts +0 -510
- package/resources/Agent.ts +0 -10
- package/resources/AgentCard.ts +0 -65
- package/resources/AgentSeed.ts +0 -119
- package/resources/IngestEvents.ts +0 -189
- package/resources/Integration.ts +0 -14
- package/resources/IssueTokens.ts +0 -29
- package/resources/Memory.ts +0 -151
- package/resources/MemoryBootstrap.ts +0 -323
- package/resources/MemoryConsolidate.ts +0 -121
- package/resources/MemoryFeed.ts +0 -48
- package/resources/MemoryMaintenance.ts +0 -95
- package/resources/MemoryReflect.ts +0 -122
- package/resources/OrgEvent.ts +0 -63
- package/resources/OrgEventCatchup.ts +0 -89
- package/resources/OrgEventMaintenance.ts +0 -37
- package/resources/SemanticSearch.ts +0 -197
- package/resources/SkillScan.ts +0 -146
- package/resources/Soul.ts +0 -10
- package/resources/SoulFeed.ts +0 -15
- package/resources/WorkspaceLatest.ts +0 -66
- package/resources/WorkspaceState.ts +0 -102
- package/resources/auth-middleware.ts +0 -501
- package/resources/embeddings-provider.ts +0 -61
- package/resources/embeddings.ts +0 -28
- package/resources/health.ts +0 -7
- package/resources/memory-feed-lib.ts +0 -22
- package/resources/table-helpers.ts +0 -46
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,8 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
9
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
10
|
-
const DEFAULT_PORT =
|
|
11
|
-
const DEFAULT_OPS_PORT =
|
|
10
|
+
const DEFAULT_PORT = 19926;
|
|
11
|
+
const DEFAULT_OPS_PORT = 19925;
|
|
12
12
|
const DEFAULT_ADMIN_USER = "admin";
|
|
13
13
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
14
14
|
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
@@ -18,6 +18,27 @@ function defaultKeysDir() {
|
|
|
18
18
|
function defaultDataDir() {
|
|
19
19
|
return join(homedir(), ".flair", "data");
|
|
20
20
|
}
|
|
21
|
+
function configPath() {
|
|
22
|
+
return join(homedir(), ".flair", "config.yaml");
|
|
23
|
+
}
|
|
24
|
+
function readPortFromConfig() {
|
|
25
|
+
try {
|
|
26
|
+
const p = configPath();
|
|
27
|
+
if (existsSync(p)) {
|
|
28
|
+
const yaml = readFileSync(p, "utf-8");
|
|
29
|
+
const m = yaml.match(/port:\s*(\d+)/);
|
|
30
|
+
if (m)
|
|
31
|
+
return Number(m[1]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function writeConfig(port) {
|
|
38
|
+
const p = configPath();
|
|
39
|
+
mkdirSync(join(homedir(), ".flair"), { recursive: true });
|
|
40
|
+
writeFileSync(p, `# Flair configuration\nport: ${port}\n`);
|
|
41
|
+
}
|
|
21
42
|
function privKeyPath(agentId, keysDir) {
|
|
22
43
|
return join(keysDir, `${agentId}.key`);
|
|
23
44
|
}
|
|
@@ -48,17 +69,8 @@ function b64url(bytes) {
|
|
|
48
69
|
}
|
|
49
70
|
async function api(method, path, body) {
|
|
50
71
|
// Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const configPath = join(homedir(), ".flair", "config.yaml");
|
|
54
|
-
if (existsSync(configPath)) {
|
|
55
|
-
const yaml = readFileSync(configPath, "utf-8");
|
|
56
|
-
const portMatch = yaml.match(/port:\s*(\d+)/);
|
|
57
|
-
if (portMatch)
|
|
58
|
-
defaultUrl = `http://127.0.0.1:${portMatch[1]}`;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
catch { /* ignore config read errors */ }
|
|
72
|
+
const savedPort = readPortFromConfig();
|
|
73
|
+
const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
62
74
|
const base = process.env.FLAIR_URL || defaultUrl;
|
|
63
75
|
// Auth resolution order:
|
|
64
76
|
// 1. FLAIR_TOKEN env → Bearer token (backward compat)
|
|
@@ -231,6 +243,7 @@ program
|
|
|
231
243
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
232
244
|
.option("--data-dir <dir>", "Harper data directory")
|
|
233
245
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
246
|
+
.option("--skip-soul", "Skip interactive personality setup")
|
|
234
247
|
.action(async (opts) => {
|
|
235
248
|
const agentId = opts.agentId;
|
|
236
249
|
const httpPort = Number(opts.port);
|
|
@@ -260,9 +273,19 @@ program
|
|
|
260
273
|
if (!bin)
|
|
261
274
|
throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
|
|
262
275
|
mkdirSync(dataDir, { recursive: true });
|
|
276
|
+
const opsSocket = join(dataDir, "operations-server");
|
|
277
|
+
const harperSetConfig = JSON.stringify({
|
|
278
|
+
rootPath: dataDir,
|
|
279
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
280
|
+
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
|
|
281
|
+
mqtt: { network: { port: null }, webSocket: false },
|
|
282
|
+
localStudio: { enabled: false },
|
|
283
|
+
authentication: { authorizeLocal: true, enableSessions: true },
|
|
284
|
+
});
|
|
263
285
|
const env = {
|
|
264
286
|
...process.env,
|
|
265
287
|
ROOTPATH: dataDir,
|
|
288
|
+
HARPER_SET_CONFIG: harperSetConfig,
|
|
266
289
|
DEFAULTS_MODE: "dev",
|
|
267
290
|
HDB_ADMIN_USERNAME: adminUser,
|
|
268
291
|
HDB_ADMIN_PASSWORD: adminPass,
|
|
@@ -272,7 +295,10 @@ program
|
|
|
272
295
|
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
273
296
|
LOCAL_STUDIO: "false",
|
|
274
297
|
};
|
|
275
|
-
// Install
|
|
298
|
+
// Install Harper (creates system database, admin user, config file).
|
|
299
|
+
// IMPORTANT: Do NOT pre-create harper-config.yaml — Harper's install checks
|
|
300
|
+
// for its existence to detect existing installations. If found, it skips
|
|
301
|
+
// install and tries to read the (empty) database, causing a crash.
|
|
276
302
|
console.log("Installing Harper...");
|
|
277
303
|
await new Promise((resolve, reject) => {
|
|
278
304
|
let output = "";
|
|
@@ -281,17 +307,21 @@ program
|
|
|
281
307
|
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
282
308
|
install.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`)));
|
|
283
309
|
install.on("error", reject);
|
|
284
|
-
setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); },
|
|
310
|
+
setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 60_000);
|
|
285
311
|
});
|
|
286
|
-
// Start (detached)
|
|
312
|
+
// Start Harper in dev mode (detached). Dev mode sets authorizeLocal=true
|
|
313
|
+
// which allows our Ed25519 middleware to handle auth while internal
|
|
314
|
+
// cross-resource calls (e.g. SemanticSearch → Memory) pass through.
|
|
287
315
|
console.log(`Starting Harper on port ${httpPort}...`);
|
|
288
|
-
const proc = spawn(process.execPath, [bin, "
|
|
316
|
+
const proc = spawn(process.execPath, [bin, "dev", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
289
317
|
proc.unref();
|
|
290
318
|
}
|
|
291
319
|
console.log("Waiting for Harper health check...");
|
|
292
320
|
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
293
321
|
console.log("Harper is healthy ✓");
|
|
294
322
|
}
|
|
323
|
+
// Persist port to config so other commands can find this instance
|
|
324
|
+
writeConfig(httpPort);
|
|
295
325
|
// Generate or reuse keypair
|
|
296
326
|
mkdirSync(keysDir, { recursive: true });
|
|
297
327
|
const privPath = privKeyPath(agentId, keysDir);
|
|
@@ -335,10 +365,49 @@ program
|
|
|
335
365
|
console.log(` ${adminPass}`);
|
|
336
366
|
}
|
|
337
367
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
368
|
+
// ── First-run soul setup ──────────────────────────────────────────────
|
|
369
|
+
// Interactive prompts to set initial personality. Skipped with --skip-soul
|
|
370
|
+
// or when stdin is not a TTY (CI, scripts, piped input).
|
|
371
|
+
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
372
|
+
console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
|
|
373
|
+
const { createInterface } = await import("node:readline");
|
|
374
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
375
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
376
|
+
const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
|
|
377
|
+
const project = await ask(" What project is it working on?\n > ");
|
|
378
|
+
const standards = await ask(" Any coding standards or preferences?\n > ");
|
|
379
|
+
rl.close();
|
|
380
|
+
// Write non-empty answers as soul entries
|
|
381
|
+
const soulEntries = [];
|
|
382
|
+
if (role.trim())
|
|
383
|
+
soulEntries.push(["role", role.trim()]);
|
|
384
|
+
if (project.trim())
|
|
385
|
+
soulEntries.push(["project", project.trim()]);
|
|
386
|
+
if (standards.trim())
|
|
387
|
+
soulEntries.push(["standards", standards.trim()]);
|
|
388
|
+
if (soulEntries.length > 0) {
|
|
389
|
+
console.log("");
|
|
390
|
+
for (const [key, value] of soulEntries) {
|
|
391
|
+
try {
|
|
392
|
+
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
393
|
+
console.log(` ✓ soul:${key} set`);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
console.log(`\n ${soulEntries.length} soul entries saved. Bootstrap will include them.`);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
console.log("\n No soul entries — you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
338
405
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
339
406
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
407
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId };
|
|
408
|
+
mcpEnv.FLAIR_URL = httpUrl;
|
|
340
409
|
console.log(`\n MCP config (.mcp.json):`);
|
|
341
|
-
console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env":
|
|
410
|
+
console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env": ${JSON.stringify(mcpEnv)} } } }`);
|
|
342
411
|
});
|
|
343
412
|
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
344
413
|
const agent = program.command("agent").description("Manage Flair agents");
|
|
@@ -676,10 +745,11 @@ program
|
|
|
676
745
|
program
|
|
677
746
|
.command("status")
|
|
678
747
|
.description("Check Flair (Harper) instance health and agent count")
|
|
679
|
-
.option("--port <port>", "Harper HTTP port"
|
|
748
|
+
.option("--port <port>", "Harper HTTP port")
|
|
680
749
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
681
750
|
.action(async (opts) => {
|
|
682
|
-
const
|
|
751
|
+
const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
|
|
752
|
+
const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
|
|
683
753
|
let healthy = false;
|
|
684
754
|
let agentCount = null;
|
|
685
755
|
let version = null;
|
|
@@ -750,6 +820,371 @@ program
|
|
|
750
820
|
}
|
|
751
821
|
console.log("\nTo upgrade: npm install -g @tpsdev-ai/flair@latest");
|
|
752
822
|
});
|
|
823
|
+
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
824
|
+
program
|
|
825
|
+
.command("stop")
|
|
826
|
+
.description("Stop the running Flair (Harper) instance")
|
|
827
|
+
.option("--port <port>", "Harper HTTP port")
|
|
828
|
+
.action(async (opts) => {
|
|
829
|
+
const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
|
|
830
|
+
const platform = process.platform;
|
|
831
|
+
if (platform === "darwin") {
|
|
832
|
+
// macOS: try launchd first
|
|
833
|
+
const label = "ai.tpsdev.flair";
|
|
834
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
835
|
+
if (existsSync(plistPath)) {
|
|
836
|
+
try {
|
|
837
|
+
const { execSync } = await import("node:child_process");
|
|
838
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
839
|
+
console.log("✅ Flair stopped (launchd service unloaded)");
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
// launchd unload failed, try PID fallback
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// Fallback: find process by port
|
|
848
|
+
try {
|
|
849
|
+
const { execSync } = await import("node:child_process");
|
|
850
|
+
const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
851
|
+
if (lsof) {
|
|
852
|
+
const pids = lsof.split("\n").map(p => p.trim()).filter(Boolean);
|
|
853
|
+
for (const pid of pids) {
|
|
854
|
+
process.kill(Number(pid), "SIGTERM");
|
|
855
|
+
}
|
|
856
|
+
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
console.log("Flair is not running.");
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
console.log("Flair is not running (nothing found on port " + port + ").");
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
// ─── flair restart ────────────────────────────────────────────────────────────
|
|
867
|
+
program
|
|
868
|
+
.command("restart")
|
|
869
|
+
.description("Restart the Flair (Harper) instance")
|
|
870
|
+
.option("--port <port>", "Harper HTTP port")
|
|
871
|
+
.action(async (opts) => {
|
|
872
|
+
const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
|
|
873
|
+
const platform = process.platform;
|
|
874
|
+
if (platform === "darwin") {
|
|
875
|
+
const label = "ai.tpsdev.flair";
|
|
876
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
877
|
+
if (existsSync(plistPath)) {
|
|
878
|
+
try {
|
|
879
|
+
const { execSync } = await import("node:child_process");
|
|
880
|
+
const uid = process.getuid?.() ?? 501;
|
|
881
|
+
execSync(`launchctl kickstart -k user/${uid}/${label}`, { stdio: "pipe" });
|
|
882
|
+
console.log("✅ Flair restarted (launchd kickstart)");
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
console.error(`launchd restart failed: ${err.message}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
console.error("❌ No launchd service found. Run 'flair init' first.");
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
// Linux: stop + start via init
|
|
896
|
+
console.log("Stopping...");
|
|
897
|
+
try {
|
|
898
|
+
const { execSync } = await import("node:child_process");
|
|
899
|
+
const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
900
|
+
if (lsof) {
|
|
901
|
+
for (const pid of lsof.split("\n")) {
|
|
902
|
+
try {
|
|
903
|
+
process.kill(Number(pid.trim()), "SIGTERM");
|
|
904
|
+
}
|
|
905
|
+
catch { }
|
|
906
|
+
}
|
|
907
|
+
// Wait briefly for shutdown
|
|
908
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch { /* not running */ }
|
|
912
|
+
console.log("Starting...");
|
|
913
|
+
const bin = harperBin();
|
|
914
|
+
if (!bin) {
|
|
915
|
+
console.error("❌ Harper binary not found. Run 'flair init' first.");
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
const dataDir = defaultDataDir();
|
|
919
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
920
|
+
const env = {
|
|
921
|
+
...process.env,
|
|
922
|
+
ROOTPATH: dataDir,
|
|
923
|
+
DEFAULTS_MODE: "dev",
|
|
924
|
+
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
925
|
+
HDB_ADMIN_PASSWORD: adminPass,
|
|
926
|
+
HTTP_PORT: String(port),
|
|
927
|
+
LOCAL_STUDIO: "false",
|
|
928
|
+
};
|
|
929
|
+
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
930
|
+
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
931
|
+
});
|
|
932
|
+
proc.unref();
|
|
933
|
+
try {
|
|
934
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
935
|
+
console.log("✅ Flair restarted");
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
console.error("❌ Flair failed to restart within timeout");
|
|
939
|
+
process.exit(1);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
// ─── flair uninstall ──────────────────────────────────────────────────────────
|
|
944
|
+
program
|
|
945
|
+
.command("uninstall")
|
|
946
|
+
.description("Stop Flair and remove the launchd/systemd service")
|
|
947
|
+
.option("--purge", "Also remove data and keys (destructive)")
|
|
948
|
+
.action(async (opts) => {
|
|
949
|
+
const platform = process.platform;
|
|
950
|
+
const port = readPortFromConfig() ?? DEFAULT_PORT;
|
|
951
|
+
// Stop first
|
|
952
|
+
if (platform === "darwin") {
|
|
953
|
+
const label = "ai.tpsdev.flair";
|
|
954
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
955
|
+
if (existsSync(plistPath)) {
|
|
956
|
+
try {
|
|
957
|
+
const { execSync } = await import("node:child_process");
|
|
958
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
959
|
+
}
|
|
960
|
+
catch { /* best effort */ }
|
|
961
|
+
const { unlinkSync } = await import("node:fs");
|
|
962
|
+
unlinkSync(plistPath);
|
|
963
|
+
console.log("✅ Launchd service removed");
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
console.log("No launchd service found — skipping");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
// Linux: kill by port
|
|
971
|
+
try {
|
|
972
|
+
const { execSync } = await import("node:child_process");
|
|
973
|
+
const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
974
|
+
if (lsof) {
|
|
975
|
+
for (const pid of lsof.split("\n")) {
|
|
976
|
+
try {
|
|
977
|
+
process.kill(Number(pid.trim()), "SIGTERM");
|
|
978
|
+
}
|
|
979
|
+
catch { }
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
catch { /* not running */ }
|
|
984
|
+
console.log("✅ Flair process stopped");
|
|
985
|
+
}
|
|
986
|
+
// Remove config
|
|
987
|
+
const cfgPath = configPath();
|
|
988
|
+
if (existsSync(cfgPath)) {
|
|
989
|
+
const { unlinkSync } = await import("node:fs");
|
|
990
|
+
unlinkSync(cfgPath);
|
|
991
|
+
console.log("✅ Config removed");
|
|
992
|
+
}
|
|
993
|
+
if (opts.purge) {
|
|
994
|
+
const { rmSync } = await import("node:fs");
|
|
995
|
+
const dataDir = defaultDataDir();
|
|
996
|
+
const keysDir = defaultKeysDir();
|
|
997
|
+
const flairDir = join(homedir(), ".flair");
|
|
998
|
+
if (existsSync(dataDir)) {
|
|
999
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
1000
|
+
console.log("✅ Data removed: " + dataDir);
|
|
1001
|
+
}
|
|
1002
|
+
if (existsSync(keysDir)) {
|
|
1003
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
1004
|
+
console.log("✅ Keys removed: " + keysDir);
|
|
1005
|
+
}
|
|
1006
|
+
// Remove .flair dir if empty
|
|
1007
|
+
try {
|
|
1008
|
+
const { readdirSync, rmdirSync } = await import("node:fs");
|
|
1009
|
+
if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
|
|
1010
|
+
rmdirSync(flairDir);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch { /* non-empty, that's fine */ }
|
|
1014
|
+
console.log("\n🗑️ Flair fully purged");
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
console.log("\nData and keys preserved at ~/.flair/");
|
|
1018
|
+
console.log("To remove everything: flair uninstall --purge");
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
// ─── flair reembed ────────────────────────────────────────────────────────────
|
|
1022
|
+
program
|
|
1023
|
+
.command("reembed")
|
|
1024
|
+
.description("Re-generate embeddings for memories with stale or missing model tags")
|
|
1025
|
+
.requiredOption("--agent <id>", "Agent ID to re-embed memories for")
|
|
1026
|
+
.option("--stale-only", "Only re-embed memories with mismatched model tag")
|
|
1027
|
+
.option("--dry-run", "Show count without modifying")
|
|
1028
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1029
|
+
.option("--batch-size <n>", "Records per batch", "50")
|
|
1030
|
+
.option("--delay-ms <ms>", "Delay between batches (ms)", "100")
|
|
1031
|
+
.action(async (opts) => {
|
|
1032
|
+
const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
|
|
1033
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
1034
|
+
const agentId = opts.agent;
|
|
1035
|
+
const staleOnly = opts.staleOnly ?? false;
|
|
1036
|
+
const dryRun = opts.dryRun ?? false;
|
|
1037
|
+
const batchSize = Number(opts.batchSize);
|
|
1038
|
+
const delayMs = Number(opts.delayMs);
|
|
1039
|
+
const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
1040
|
+
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
1041
|
+
console.log(`Current model: ${currentModel}`);
|
|
1042
|
+
if (staleOnly)
|
|
1043
|
+
console.log("Mode: stale-only (skipping up-to-date memories)");
|
|
1044
|
+
if (dryRun)
|
|
1045
|
+
console.log("Mode: dry-run (no modifications)");
|
|
1046
|
+
console.log("");
|
|
1047
|
+
const keysDir = defaultKeysDir();
|
|
1048
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1049
|
+
if (!existsSync(privPath)) {
|
|
1050
|
+
console.error(`❌ Key not found: ${privPath}`);
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
|
|
1054
|
+
agentId, limit: 10000,
|
|
1055
|
+
});
|
|
1056
|
+
if (!searchRes.ok) {
|
|
1057
|
+
console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
1060
|
+
const data = await searchRes.json();
|
|
1061
|
+
const allMemories = data.results ?? [];
|
|
1062
|
+
const candidates = allMemories.filter((m) => {
|
|
1063
|
+
if (!m.content)
|
|
1064
|
+
return false;
|
|
1065
|
+
if (staleOnly)
|
|
1066
|
+
return !m.embeddingModel || m.embeddingModel !== currentModel;
|
|
1067
|
+
return true;
|
|
1068
|
+
});
|
|
1069
|
+
const total = candidates.length;
|
|
1070
|
+
const skipped = allMemories.length - total;
|
|
1071
|
+
console.log(`Total memories: ${allMemories.length}`);
|
|
1072
|
+
console.log(`Candidates for re-embedding: ${total}`);
|
|
1073
|
+
if (skipped > 0)
|
|
1074
|
+
console.log(`Skipped (up-to-date): ${skipped}`);
|
|
1075
|
+
if (dryRun || total === 0) {
|
|
1076
|
+
if (total === 0)
|
|
1077
|
+
console.log("\n✅ All memories are up-to-date!");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
console.log("");
|
|
1081
|
+
let processed = 0;
|
|
1082
|
+
let errors = 0;
|
|
1083
|
+
for (let i = 0; i < candidates.length; i += batchSize) {
|
|
1084
|
+
const batch = candidates.slice(i, i + batchSize);
|
|
1085
|
+
for (const memory of batch) {
|
|
1086
|
+
try {
|
|
1087
|
+
const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
|
|
1088
|
+
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
|
|
1089
|
+
});
|
|
1090
|
+
if (updateRes.ok)
|
|
1091
|
+
processed++;
|
|
1092
|
+
else
|
|
1093
|
+
errors++;
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
errors++;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const pct = Math.round(((i + batch.length) / total) * 100);
|
|
1100
|
+
process.stdout.write(`\rRe-embedded ${processed}/${total} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
|
|
1101
|
+
if (i + batchSize < candidates.length)
|
|
1102
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
1103
|
+
}
|
|
1104
|
+
console.log(`\n\n✅ Re-embedding complete: ${processed} updated, ${errors} errors`);
|
|
1105
|
+
});
|
|
1106
|
+
// ─── flair test ───────────────────────────────────────────────────────────────
|
|
1107
|
+
program
|
|
1108
|
+
.command("test")
|
|
1109
|
+
.description("Verify Flair is working: store, search, bootstrap, cleanup")
|
|
1110
|
+
.requiredOption("--agent <id>", "Agent ID to test with")
|
|
1111
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1112
|
+
.action(async (opts) => {
|
|
1113
|
+
const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
|
|
1114
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
1115
|
+
const agentId = opts.agent;
|
|
1116
|
+
const keysDir = defaultKeysDir();
|
|
1117
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1118
|
+
if (!existsSync(privPath)) {
|
|
1119
|
+
console.error(`❌ Key not found: ${privPath}`);
|
|
1120
|
+
console.error(` Run: flair init --agent-id ${agentId}`);
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
const testId = `test-${agentId}-${Date.now()}`;
|
|
1124
|
+
const testContent = `Flair test memory (${new Date().toISOString()})`;
|
|
1125
|
+
let passed = 0;
|
|
1126
|
+
let failed = 0;
|
|
1127
|
+
const check = async (name, fn) => {
|
|
1128
|
+
try {
|
|
1129
|
+
const ok = await fn();
|
|
1130
|
+
if (ok) {
|
|
1131
|
+
console.log(` ✅ ${name}`);
|
|
1132
|
+
passed++;
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
console.log(` ❌ ${name}`);
|
|
1136
|
+
failed++;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
console.log(` ❌ ${name}: ${e.message?.slice(0, 100)}`);
|
|
1141
|
+
failed++;
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
console.log(`\nFlair test (agent: ${agentId}, url: ${baseUrl})\n`);
|
|
1145
|
+
// 1. Health
|
|
1146
|
+
await check("Health check", async () => {
|
|
1147
|
+
const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1148
|
+
return res.status > 0;
|
|
1149
|
+
});
|
|
1150
|
+
// 2. Store
|
|
1151
|
+
await check("Memory store", async () => {
|
|
1152
|
+
const res = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${testId}`, {
|
|
1153
|
+
id: testId, agentId, content: testContent, durability: "ephemeral",
|
|
1154
|
+
createdAt: new Date().toISOString(), archived: false,
|
|
1155
|
+
});
|
|
1156
|
+
return res.ok;
|
|
1157
|
+
});
|
|
1158
|
+
// 3. Search
|
|
1159
|
+
await check("Semantic search", async () => {
|
|
1160
|
+
await new Promise(r => setTimeout(r, 2000)); // wait for indexing
|
|
1161
|
+
const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
|
|
1162
|
+
agentId, q: "flair test memory", limit: 5,
|
|
1163
|
+
});
|
|
1164
|
+
if (!res.ok)
|
|
1165
|
+
return false;
|
|
1166
|
+
const data = await res.json();
|
|
1167
|
+
return (data.results?.length ?? 0) > 0;
|
|
1168
|
+
});
|
|
1169
|
+
// 4. Bootstrap
|
|
1170
|
+
await check("Bootstrap context", async () => {
|
|
1171
|
+
const res = await authFetch(baseUrl, agentId, privPath, "POST", "/BootstrapMemories", {
|
|
1172
|
+
agentId, maxTokens: 1000,
|
|
1173
|
+
});
|
|
1174
|
+
if (!res.ok)
|
|
1175
|
+
return false;
|
|
1176
|
+
const data = await res.json();
|
|
1177
|
+
return (data.context?.length ?? 0) > 0;
|
|
1178
|
+
});
|
|
1179
|
+
// 5. Cleanup
|
|
1180
|
+
await check("Memory delete", async () => {
|
|
1181
|
+
const res = await authFetch(baseUrl, agentId, privPath, "DELETE", `/Memory/${testId}`);
|
|
1182
|
+
return res.ok || res.status === 204;
|
|
1183
|
+
});
|
|
1184
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
1185
|
+
if (failed > 0)
|
|
1186
|
+
process.exit(1);
|
|
1187
|
+
});
|
|
753
1188
|
// ─── Legacy identity/memory/soul commands (preserved) ────────────────────────
|
|
754
1189
|
const identity = program.command("identity").description("Legacy identity commands");
|
|
755
1190
|
identity.command("register")
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord } from "./table-helpers.js";
|
|
3
3
|
import { isAdmin } from "./auth-middleware.js";
|
|
4
|
-
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
|
+
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
|
+
import { scanContent, isStrictMode } from "./content-safety.js";
|
|
6
|
+
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
5
7
|
export class Memory extends databases.flair.Memory {
|
|
6
8
|
/**
|
|
7
9
|
* Override search() to scope collection GETs by authenticated agent.
|
|
@@ -55,6 +57,14 @@ export class Memory extends databases.flair.Memory {
|
|
|
55
57
|
return super.search(conditions);
|
|
56
58
|
}
|
|
57
59
|
async post(content, context) {
|
|
60
|
+
// Rate limiting — use authenticated agent ID, not client-supplied body field
|
|
61
|
+
const ctx = this.getContext?.();
|
|
62
|
+
const authenticatedAgent = ctx?.request?.tpsAgent;
|
|
63
|
+
if (authenticatedAgent) {
|
|
64
|
+
const rl = checkRateLimit(authenticatedAgent, "general");
|
|
65
|
+
if (!rl.allowed)
|
|
66
|
+
return rateLimitResponse(rl.retryAfterMs, "write");
|
|
67
|
+
}
|
|
58
68
|
content.durability ||= "standard";
|
|
59
69
|
content.createdAt = new Date().toISOString();
|
|
60
70
|
content.updatedAt = content.createdAt;
|
|
@@ -82,22 +92,61 @@ export class Memory extends databases.flair.Memory {
|
|
|
82
92
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
83
93
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
84
94
|
}
|
|
95
|
+
// Content safety scan
|
|
96
|
+
if (content.content) {
|
|
97
|
+
const safety = scanContent(content.content);
|
|
98
|
+
if (!safety.safe) {
|
|
99
|
+
if (isStrictMode()) {
|
|
100
|
+
return new Response(JSON.stringify({
|
|
101
|
+
error: "content_safety_violation",
|
|
102
|
+
flags: safety.flags,
|
|
103
|
+
message: "Content flagged for potential prompt injection. Set FLAIR_CONTENT_SAFETY=warn to allow with tagging.",
|
|
104
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
105
|
+
}
|
|
106
|
+
content._safetyFlags = safety.flags;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
85
109
|
// Generate embedding from content text
|
|
86
110
|
if (content.content && !content.embedding) {
|
|
87
111
|
const vec = await getEmbedding(content.content);
|
|
88
|
-
if (vec)
|
|
112
|
+
if (vec) {
|
|
89
113
|
content.embedding = vec;
|
|
114
|
+
content.embeddingModel = getModelId();
|
|
115
|
+
}
|
|
90
116
|
}
|
|
91
117
|
return super.post(content);
|
|
92
118
|
}
|
|
93
119
|
async put(content) {
|
|
94
120
|
const now = new Date().toISOString();
|
|
95
121
|
content.updatedAt = now;
|
|
122
|
+
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
123
|
+
content.archived = content.archived ?? false;
|
|
124
|
+
content.createdAt = content.createdAt ?? now;
|
|
125
|
+
// Content safety scan on updated content
|
|
126
|
+
if (content.content) {
|
|
127
|
+
const safety = scanContent(content.content);
|
|
128
|
+
if (!safety.safe) {
|
|
129
|
+
if (isStrictMode()) {
|
|
130
|
+
return new Response(JSON.stringify({
|
|
131
|
+
error: "content_safety_violation",
|
|
132
|
+
flags: safety.flags,
|
|
133
|
+
message: "Content flagged for potential prompt injection.",
|
|
134
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
135
|
+
}
|
|
136
|
+
content._safetyFlags = safety.flags;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Clear previous flags if content is now clean
|
|
140
|
+
content._safetyFlags = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
96
143
|
// Re-generate embedding if content changed
|
|
97
144
|
if (content.content && !content.embedding) {
|
|
98
145
|
const vec = await getEmbedding(content.content);
|
|
99
|
-
if (vec)
|
|
146
|
+
if (vec) {
|
|
100
147
|
content.embedding = vec;
|
|
148
|
+
content.embeddingModel = getModelId();
|
|
149
|
+
}
|
|
101
150
|
}
|
|
102
151
|
// If archiving, record who + when
|
|
103
152
|
if (content.archived === true && !content.archivedAt) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
3
|
+
import { wrapUntrusted } from "./content-safety.js";
|
|
3
4
|
/**
|
|
4
5
|
* POST /MemoryBootstrap
|
|
5
6
|
*
|
|
@@ -24,7 +25,12 @@ function formatMemory(m, supersedes) {
|
|
|
24
25
|
const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
|
|
25
26
|
const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
|
|
26
27
|
const chain = m.supersedes ? " [supersedes earlier decision]" : "";
|
|
27
|
-
|
|
28
|
+
const base = `${tag} ${m.content}${date}${chain}`;
|
|
29
|
+
// Wrap flagged memories in safety delimiters
|
|
30
|
+
if (m._safetyFlags && Array.isArray(m._safetyFlags) && m._safetyFlags.length > 0) {
|
|
31
|
+
return wrapUntrusted(base, m._source);
|
|
32
|
+
}
|
|
33
|
+
return base;
|
|
28
34
|
}
|
|
29
35
|
export class BootstrapMemories extends Resource {
|
|
30
36
|
async post(data, _context) {
|