@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.
Files changed (39) hide show
  1. package/README.md +49 -33
  2. package/config.yaml +4 -2
  3. package/dist/cli.js +455 -20
  4. package/dist/resources/Memory.js +52 -3
  5. package/dist/resources/MemoryBootstrap.js +7 -1
  6. package/dist/resources/SemanticSearch.js +129 -56
  7. package/dist/resources/auth-middleware.js +37 -10
  8. package/dist/resources/content-safety.js +62 -0
  9. package/dist/resources/embeddings-provider.js +86 -7
  10. package/dist/resources/rate-limiter.js +118 -0
  11. package/package.json +2 -3
  12. package/resources/A2AAdapter.ts +0 -510
  13. package/resources/Agent.ts +0 -10
  14. package/resources/AgentCard.ts +0 -65
  15. package/resources/AgentSeed.ts +0 -119
  16. package/resources/IngestEvents.ts +0 -189
  17. package/resources/Integration.ts +0 -14
  18. package/resources/IssueTokens.ts +0 -29
  19. package/resources/Memory.ts +0 -151
  20. package/resources/MemoryBootstrap.ts +0 -323
  21. package/resources/MemoryConsolidate.ts +0 -121
  22. package/resources/MemoryFeed.ts +0 -48
  23. package/resources/MemoryMaintenance.ts +0 -95
  24. package/resources/MemoryReflect.ts +0 -122
  25. package/resources/OrgEvent.ts +0 -63
  26. package/resources/OrgEventCatchup.ts +0 -89
  27. package/resources/OrgEventMaintenance.ts +0 -37
  28. package/resources/SemanticSearch.ts +0 -197
  29. package/resources/SkillScan.ts +0 -146
  30. package/resources/Soul.ts +0 -10
  31. package/resources/SoulFeed.ts +0 -15
  32. package/resources/WorkspaceLatest.ts +0 -66
  33. package/resources/WorkspaceState.ts +0 -102
  34. package/resources/auth-middleware.ts +0 -501
  35. package/resources/embeddings-provider.ts +0 -61
  36. package/resources/embeddings.ts +0 -28
  37. package/resources/health.ts +0 -7
  38. package/resources/memory-feed-lib.ts +0 -22
  39. 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 = 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)
@@ -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}`)); }, 20_000);
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, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
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": { "FLAIR_AGENT_ID": "${agentId}" } } } }`);
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", String(DEFAULT_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 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}`;
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")
@@ -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
- return `${tag} ${m.content}${date}${chain}`;
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) {