@tpsdev-ai/flair 0.3.19 → 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 CHANGED
@@ -88,6 +88,12 @@ flair agent add mybot --name "My Bot" --role assistant
88
88
 
89
89
  # Check everything is working
90
90
  flair status
91
+
92
+ # Lifecycle management
93
+ flair stop # Stop the Flair instance
94
+ flair restart # Restart the Flair instance
95
+ flair uninstall # Remove the service (keeps data)
96
+ flair uninstall --purge # Remove everything including data and keys
91
97
  ```
92
98
 
93
99
  That's it. Your agent now has identity and memory.
@@ -163,7 +169,7 @@ npm install @tpsdev-ai/flair-client
163
169
  import { FlairClient } from '@tpsdev-ai/flair-client'
164
170
 
165
171
  const flair = new FlairClient({
166
- url: 'http://localhost:9926', // or remote: https://flair.example.com
172
+ url: 'http://localhost:19926', // or remote: https://flair.example.com
167
173
  agentId: 'mybot',
168
174
  // key auto-resolved from ~/.flair/keys/mybot.key
169
175
  })
@@ -190,17 +196,17 @@ Flair is a pure HTTP API. Use it from Python, Go, Rust, shell scripts — anythi
190
196
  ```bash
191
197
  # Search memories
192
198
  curl -H "Authorization: TPS-Ed25519 mybot:$TS:$NONCE:$SIG" \
193
- -X POST http://localhost:9926/SemanticSearch \
199
+ -X POST http://localhost:19926/SemanticSearch \
194
200
  -d '{"agentId": "mybot", "q": "deployment procedure", "limit": 5}'
195
201
 
196
202
  # Write a memory
197
203
  curl -H "Authorization: TPS-Ed25519 mybot:$TS:$NONCE:$SIG" \
198
- -X PUT http://localhost:9926/Memory/mybot-123 \
204
+ -X PUT http://localhost:19926/Memory/mybot-123 \
199
205
  -d '{"id": "mybot-123", "agentId": "mybot", "content": "...", "durability": "standard"}'
200
206
 
201
207
  # Bootstrap (soul + recent memories)
202
208
  curl -H "Authorization: TPS-Ed25519 mybot:$TS:$NONCE:$SIG" \
203
- -X POST http://localhost:9926/BootstrapMemories \
209
+ -X POST http://localhost:19926/BootstrapMemories \
204
210
  -d '{"agentId": "mybot", "maxTokens": 4000}'
205
211
  ```
206
212
 
@@ -245,15 +251,22 @@ flair init
245
251
 
246
252
  Your data stays on your machine. Best for personal agents, dev teams, and privacy-first setups. Flair runs as a single Harper process — no Docker, no cloud, no external services.
247
253
 
254
+ #### Custom Ports
255
+ If the default port (`19926`) is already in use, initialize with a custom port:
256
+ ```bash
257
+ flair init --port 8000
258
+ ```
259
+ Flair will automatically remember this port for future CLI commands by saving it to `~/.flair/config.yaml`.
260
+
248
261
  ### Remote Server
249
262
 
250
263
  Run Flair on a VPS or cloud instance. Agents connect over HTTPS:
251
264
 
252
265
  ```bash
253
266
  # On the server
254
- flair init --port 9926
267
+ flair init --port 19926
255
268
  # Agents connect with:
256
- FLAIR_URL=https://your-server:9926 flair agent add mybot
269
+ FLAIR_URL=https://your-server:19926 flair agent add mybot
257
270
  ```
258
271
 
259
272
  Good for teams with multiple machines or always-on agents.
package/config.yaml CHANGED
@@ -1,8 +1,10 @@
1
1
  name: flair
2
2
  rest: true
3
3
 
4
- http:
5
- port: 8787
4
+ ## Port is configured via CLI (flair init --port) or HTTP_PORT env var.
5
+ ## Omitted here to avoid conflicts with different deployment scenarios.
6
+ # http:
7
+ # port: 19926
6
8
 
7
9
  graphqlSchema:
8
10
  files: schemas/*.graphql
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 = 9926;
11
- const DEFAULT_OPS_PORT = 9925;
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
- let defaultUrl = "http://127.0.0.1:9926";
52
- try {
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)
@@ -261,9 +273,19 @@ program
261
273
  if (!bin)
262
274
  throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
263
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
+ });
264
285
  const env = {
265
286
  ...process.env,
266
287
  ROOTPATH: dataDir,
288
+ HARPER_SET_CONFIG: harperSetConfig,
267
289
  DEFAULTS_MODE: "dev",
268
290
  HDB_ADMIN_USERNAME: adminUser,
269
291
  HDB_ADMIN_PASSWORD: adminPass,
@@ -273,7 +295,10 @@ program
273
295
  OPERATIONSAPI_NETWORK_PORT: String(opsPort),
274
296
  LOCAL_STUDIO: "false",
275
297
  };
276
- // 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.
277
302
  console.log("Installing Harper...");
278
303
  await new Promise((resolve, reject) => {
279
304
  let output = "";
@@ -282,17 +307,21 @@ program
282
307
  install.stderr?.on("data", (d) => { output += d.toString(); });
283
308
  install.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`)));
284
309
  install.on("error", reject);
285
- setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 20_000);
310
+ setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 60_000);
286
311
  });
287
- // 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.
288
315
  console.log(`Starting Harper on port ${httpPort}...`);
289
- const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
316
+ const proc = spawn(process.execPath, [bin, "dev", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
290
317
  proc.unref();
291
318
  }
292
319
  console.log("Waiting for Harper health check...");
293
320
  await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
294
321
  console.log("Harper is healthy ✓");
295
322
  }
323
+ // Persist port to config so other commands can find this instance
324
+ writeConfig(httpPort);
296
325
  // Generate or reuse keypair
297
326
  mkdirSync(keysDir, { recursive: true });
298
327
  const privPath = privKeyPath(agentId, keysDir);
@@ -375,8 +404,10 @@ program
375
404
  }
376
405
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
377
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;
378
409
  console.log(`\n MCP config (.mcp.json):`);
379
- console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env": { "FLAIR_AGENT_ID": "${agentId}" } } } }`);
410
+ console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env": ${JSON.stringify(mcpEnv)} } } }`);
380
411
  });
381
412
  // ─── flair agent ─────────────────────────────────────────────────────────────
382
413
  const agent = program.command("agent").description("Manage Flair agents");
@@ -714,10 +745,11 @@ program
714
745
  program
715
746
  .command("status")
716
747
  .description("Check Flair (Harper) instance health and agent count")
717
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
748
+ .option("--port <port>", "Harper HTTP port")
718
749
  .option("--url <url>", "Flair base URL (overrides --port)")
719
750
  .action(async (opts) => {
720
- const baseUrl = opts.url ?? `http://127.0.0.1:${opts.port}`;
751
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
752
+ const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
721
753
  let healthy = false;
722
754
  let agentCount = null;
723
755
  let version = null;
@@ -788,6 +820,371 @@ program
788
820
  }
789
821
  console.log("\nTo upgrade: npm install -g @tpsdev-ai/flair@latest");
790
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
+ });
791
1188
  // ─── Legacy identity/memory/soul commands (preserved) ────────────────────────
792
1189
  const identity = program.command("identity").description("Legacy identity commands");
793
1190
  identity.command("register")
@@ -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) {