@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 +19 -6
- package/config.yaml +4 -2
- package/dist/cli.js +417 -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 -2
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
267
|
+
flair init --port 19926
|
|
255
268
|
# Agents connect with:
|
|
256
|
-
FLAIR_URL=https://your-server:
|
|
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
|
-
|
|
5
|
-
|
|
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 =
|
|
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)
|
|
@@ -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}`)); },
|
|
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, "
|
|
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":
|
|
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"
|
|
748
|
+
.option("--port <port>", "Harper HTTP port")
|
|
718
749
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
719
750
|
.action(async (opts) => {
|
|
720
|
-
const
|
|
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")
|
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) {
|