@tpsdev-ai/flair 0.31.1 → 0.33.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 +80 -54
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +349 -117
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/in-process-api.js +386 -0
- package/dist/resources/mcp-tools.js +40 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +175 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/mcp-clients.md +4 -2
- package/docs/quickstart.md +29 -4
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +1 -1
- package/docs/upgrade.md +6 -2
- package/package.json +7 -1
package/dist/cli.js
CHANGED
|
@@ -269,7 +269,19 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
269
269
|
}
|
|
270
270
|
catch { /* best effort */ }
|
|
271
271
|
const legacyContent = readFileSync(resolved.plistPath, "utf-8");
|
|
272
|
-
|
|
272
|
+
// Use a function replacer to avoid $-sensitivity in the replacement
|
|
273
|
+
// string (flair#919). String.prototype.replace interprets $&, $', $`
|
|
274
|
+
// etc. in the replacement even when the search value is a plain string.
|
|
275
|
+
const labelSearch = `<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`;
|
|
276
|
+
const labelReplacement = `<key>Label</key><string>${newLabel}</string>`;
|
|
277
|
+
const newContent = legacyContent.replace(labelSearch, () => labelReplacement);
|
|
278
|
+
// Refuse to propagate a malformed plist: if the Label wasn't found,
|
|
279
|
+
// the plist is not what we expect and migration must not write it.
|
|
280
|
+
if (newContent === legacyContent) {
|
|
281
|
+
throw new Error(`Legacy plist at ${resolved.plistPath} does not contain the expected ` +
|
|
282
|
+
`Label key — it may be malformed or from an unknown Flair version. ` +
|
|
283
|
+
`Remove it manually and re-run 'flair init'.`);
|
|
284
|
+
}
|
|
273
285
|
writeFileSync(newPlistPath, newContent);
|
|
274
286
|
try {
|
|
275
287
|
unlinkSync(resolved.plistPath);
|
|
@@ -277,6 +289,58 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
277
289
|
catch { /* best effort */ }
|
|
278
290
|
return { migrated: true, label: newLabel, plistPath: newPlistPath };
|
|
279
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Clean up a pre-flair#693 legacy launchd plist (ai.tpsdev.flair) during
|
|
294
|
+
* init, but ONLY when it belongs to the data dir being initialised.
|
|
295
|
+
*
|
|
296
|
+
* flair#966: the legacy plist is a single global label — init must not
|
|
297
|
+
* unload/delete it unless ROOTPATH proves it serves this data dir.
|
|
298
|
+
*
|
|
299
|
+
* `runLaunchctl` is injected so tests can record/mock without touching
|
|
300
|
+
* real launchd. The caller (init) passes a real execSync wrapper; tests
|
|
301
|
+
* pass a recording stub.
|
|
302
|
+
*/
|
|
303
|
+
function cleanupLegacyLaunchdPlist(dataDir, plistDir, runLaunchctl) {
|
|
304
|
+
const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
|
|
305
|
+
if (!existsSync(legacyPlistPath))
|
|
306
|
+
return { action: "none" };
|
|
307
|
+
const legacyRootPath = readPlistRootPath(legacyPlistPath);
|
|
308
|
+
const legacyOwnedByUs = legacyRootPath !== null && resolve(legacyRootPath) === resolve(dataDir);
|
|
309
|
+
if (legacyOwnedByUs) {
|
|
310
|
+
let unloadFailed;
|
|
311
|
+
let deleteFailed;
|
|
312
|
+
try {
|
|
313
|
+
runLaunchctl(`launchctl unload "${legacyPlistPath}"`);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
unloadFailed = err?.message ?? String(err);
|
|
317
|
+
console.error(`Failed to unload legacy launchd service (${LEGACY_LAUNCHD_LABEL}): ` +
|
|
318
|
+
`${unloadFailed}. ` +
|
|
319
|
+
`The plist at ${legacyPlistPath} may still be loaded — ` +
|
|
320
|
+
`unload it manually with: launchctl unload "${legacyPlistPath}"`);
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
unlinkSync(legacyPlistPath);
|
|
324
|
+
console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
deleteFailed = err?.message ?? String(err);
|
|
328
|
+
console.error(`Failed to remove legacy launchd plist at ${legacyPlistPath}: ` +
|
|
329
|
+
`${deleteFailed}. Remove it manually.`);
|
|
330
|
+
}
|
|
331
|
+
return { action: "unloaded", unloadFailed, deleteFailed };
|
|
332
|
+
}
|
|
333
|
+
if (legacyRootPath !== null) {
|
|
334
|
+
console.log(`Skipped legacy launchd cleanup: the plist at ${legacyPlistPath} ` +
|
|
335
|
+
`belongs to data dir ${resolve(legacyRootPath)}, not ${resolve(dataDir)} — ` +
|
|
336
|
+
`that is a different Flair instance.`);
|
|
337
|
+
return { action: "skipped-foreign", foreignDataDir: resolve(legacyRootPath) };
|
|
338
|
+
}
|
|
339
|
+
console.log(`Skipped legacy launchd cleanup: could not determine which data dir ` +
|
|
340
|
+
`the plist at ${legacyPlistPath} serves. ` +
|
|
341
|
+
`If it is yours, remove it manually with: rm "${legacyPlistPath}"`);
|
|
342
|
+
return { action: "skipped-unknown" };
|
|
343
|
+
}
|
|
280
344
|
/**
|
|
281
345
|
* Load + start `dataDir`'s launchd service, migrating off a pre-flair#693
|
|
282
346
|
* legacy registration FIRST if one is found (migrateLegacyLaunchdLabel
|
|
@@ -290,6 +354,13 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
290
354
|
*/
|
|
291
355
|
function ensureLaunchdServiceLoaded(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
|
|
292
356
|
const migration = migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir);
|
|
357
|
+
// Unload first so a rewritten plist is re-read (flair#872).
|
|
358
|
+
// launchd caches the environment of an already-loaded job; load
|
|
359
|
+
// alone does not pick up changes to the plist on disk.
|
|
360
|
+
try {
|
|
361
|
+
runLaunchctl(`launchctl unload "${migration.plistPath}"`);
|
|
362
|
+
}
|
|
363
|
+
catch { /* not loaded, etc. — best effort */ }
|
|
293
364
|
try {
|
|
294
365
|
runLaunchctl(`launchctl load "${migration.plistPath}"`);
|
|
295
366
|
}
|
|
@@ -2933,25 +3004,20 @@ program
|
|
|
2933
3004
|
const plistDir = defaultLaunchAgentsDir();
|
|
2934
3005
|
mkdirSync(plistDir, { recursive: true });
|
|
2935
3006
|
const plistPath = launchdPlistPath(label, plistDir);
|
|
2936
|
-
// flair#693
|
|
3007
|
+
// flair#693 + flair#966: a pre-flair#693 install registered under
|
|
2937
3008
|
// the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
|
|
2938
3009
|
// content below (it has the current ports/creds in hand), so
|
|
2939
3010
|
// migration here is just "clean up the old registration" —
|
|
2940
3011
|
// unload + remove it BEFORE writing the new one, so re-running
|
|
2941
3012
|
// init never leaves two services behind for this data dir.
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
unlinkSync(legacyPlistPath);
|
|
2951
|
-
}
|
|
2952
|
-
catch { /* best effort */ }
|
|
2953
|
-
console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
|
|
2954
|
-
}
|
|
3013
|
+
//
|
|
3014
|
+
// flair#966: the legacy plist is NOT scoped to this data dir —
|
|
3015
|
+
// it is a single global label. cleanupLegacyLaunchdPlist reads
|
|
3016
|
+
// ROOTPATH to establish ownership before touching it.
|
|
3017
|
+
cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
|
|
3018
|
+
const { execSync } = require("node:child_process");
|
|
3019
|
+
execSync(cmd, { stdio: "pipe" });
|
|
3020
|
+
});
|
|
2955
3021
|
const opsSocket = join(dataDir, "operations-server");
|
|
2956
3022
|
// authorizeLocal: false (flair#654) — same posture as the initial spawn
|
|
2957
3023
|
// above; the launchd-managed process must not diverge from it.
|
|
@@ -9618,17 +9684,38 @@ program
|
|
|
9618
9684
|
// PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
|
|
9619
9685
|
// every process holding ANY socket on the port, so `flair stop` could kill
|
|
9620
9686
|
// itself (leaving Flair running) or kill an unrelated client of it.
|
|
9687
|
+
//
|
|
9688
|
+
// Attribution guard (flair#915): the port is not an identity. Refuse to
|
|
9689
|
+
// SIGTERM a PID that cannot be attributed to this instance.
|
|
9621
9690
|
try {
|
|
9622
9691
|
const { execSync } = await import("node:child_process");
|
|
9623
9692
|
const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
9624
9693
|
if (pids.length > 0) {
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9694
|
+
const dataDir = defaultDataDir();
|
|
9695
|
+
const harperPid = readHarperPid(dataDir);
|
|
9696
|
+
if (harperPid !== null && !pids.includes(harperPid)) {
|
|
9697
|
+
console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
9698
|
+
+ `do not match this Flair instance (PID ${harperPid}). `
|
|
9699
|
+
+ `Not stopping — cannot attribute the process to this instance. `
|
|
9700
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
9701
|
+
process.exit(1);
|
|
9702
|
+
}
|
|
9703
|
+
else if (harperPid === null) {
|
|
9704
|
+
console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
9705
|
+
+ `but no PID file in data directory — not a running Flair instance. `
|
|
9706
|
+
+ `Not stopping — cannot attribute the process to this instance. `
|
|
9707
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
9708
|
+
process.exit(1);
|
|
9709
|
+
}
|
|
9710
|
+
else {
|
|
9711
|
+
for (const pid of pids) {
|
|
9712
|
+
try {
|
|
9713
|
+
process.kill(pid, "SIGTERM");
|
|
9714
|
+
}
|
|
9715
|
+
catch { /* already gone */ }
|
|
9628
9716
|
}
|
|
9629
|
-
|
|
9717
|
+
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
9630
9718
|
}
|
|
9631
|
-
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
9632
9719
|
}
|
|
9633
9720
|
else {
|
|
9634
9721
|
console.log("Flair is not running.");
|
|
@@ -9741,20 +9828,28 @@ program
|
|
|
9741
9828
|
* Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
|
|
9742
9829
|
* extracted ROOTPATH path ever reaches a message.
|
|
9743
9830
|
*/
|
|
9744
|
-
|
|
9745
|
-
|
|
9831
|
+
/**
|
|
9832
|
+
* Read the ROOTPATH declared in a launchd plist, or null if it cannot be
|
|
9833
|
+
* determined (file missing, unreadable, or no ROOTPATH key).
|
|
9834
|
+
*
|
|
9835
|
+
* The plist stores this XML-escaped (buildLaunchdPlist), so the returned
|
|
9836
|
+
* value is decoded through unescapeXml before being returned — a data dir
|
|
9837
|
+
* containing `&` is on disk as `&` and this returns the literal `&`.
|
|
9838
|
+
*
|
|
9839
|
+
* Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD.
|
|
9840
|
+
*/
|
|
9841
|
+
export function readPlistRootPath(plistPath) {
|
|
9746
9842
|
try {
|
|
9747
9843
|
const raw = readFileSync(plistPath, "utf-8");
|
|
9748
9844
|
const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
|
|
9749
|
-
|
|
9750
|
-
// containing `&` is on disk as `&`. Decode before comparing, or the
|
|
9751
|
-
// path would never equal itself and this guard would refuse a legitimate
|
|
9752
|
-
// stop/start on any instance whose path contains an escaped character.
|
|
9753
|
-
declared = m ? unescapeXml(m[1]) : null;
|
|
9845
|
+
return m ? unescapeXml(m[1]) : null;
|
|
9754
9846
|
}
|
|
9755
9847
|
catch {
|
|
9756
|
-
return;
|
|
9848
|
+
return null;
|
|
9757
9849
|
}
|
|
9850
|
+
}
|
|
9851
|
+
export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
9852
|
+
const declared = readPlistRootPath(plistPath);
|
|
9758
9853
|
if (declared === null)
|
|
9759
9854
|
return;
|
|
9760
9855
|
if (resolve(declared) === resolve(dataDir))
|
|
@@ -9785,17 +9880,29 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
|
9785
9880
|
* wrong today whenever the port does not match.
|
|
9786
9881
|
*/
|
|
9787
9882
|
function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
|
|
9788
|
-
|
|
9789
|
-
|
|
9883
|
+
// (flair#915) Apply the attribution check for ALL data directories, not
|
|
9884
|
+
// just non-default ones. The default-dir bypass was the residual gap that
|
|
9885
|
+
// #910 left behind — it allowed an unattributed SIGTERM on the default
|
|
9886
|
+
// install's port. The old concern (false refusal when hdb.pid is missing)
|
|
9887
|
+
// is actually the RIGHT behavior: no PID file means we cannot attribute the
|
|
9888
|
+
// listener, so we refuse. That is safer than killing the wrong process.
|
|
9790
9889
|
const expected = readHarperPid(dataDir);
|
|
9791
|
-
|
|
9890
|
+
// No PID file — Harper is not (or was not) running in this directory.
|
|
9891
|
+
// The port is stale or held by something else; refuse to SIGTERM it.
|
|
9892
|
+
if (expected === null) {
|
|
9893
|
+
throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
|
|
9894
|
+
+ `${resolve(dataDir)}, so that is not a running instance. `
|
|
9895
|
+
+ `Stopping by port alone would signal a process we cannot attribute. `
|
|
9896
|
+
+ `If it is not Flair, stop it manually.`);
|
|
9897
|
+
}
|
|
9898
|
+
// PID file exists — the PID on the port must be Harper.
|
|
9899
|
+
if (listeningPids.includes(expected))
|
|
9792
9900
|
return;
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
`
|
|
9798
|
-
`Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
|
|
9901
|
+
throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
|
|
9902
|
+
+ `is not the process listening on ${port}. `
|
|
9903
|
+
+ `Stopping by port alone would signal a different instance. `
|
|
9904
|
+
+ `Pass --port with the port ${resolve(dataDir)} actually serves, `
|
|
9905
|
+
+ `or stop the process manually.`);
|
|
9799
9906
|
}
|
|
9800
9907
|
/**
|
|
9801
9908
|
* Stop the local Flair (Harper) process — launchd `stop` on darwin when a
|
|
@@ -9835,18 +9942,15 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
9835
9942
|
assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
|
|
9836
9943
|
try {
|
|
9837
9944
|
const { execSync } = await import("node:child_process");
|
|
9838
|
-
//
|
|
9839
|
-
try {
|
|
9840
|
-
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
9841
|
-
}
|
|
9842
|
-
catch { }
|
|
9843
|
-
// Capture the current PID *before* stopping so callers that
|
|
9945
|
+
// Capture the current PID *before* unloading so callers that
|
|
9844
9946
|
// immediately restart can verify exit. Without this, waitForHealth
|
|
9845
9947
|
// can race against the still-shutting-down old process and return
|
|
9846
|
-
// success before
|
|
9948
|
+
// success before the new one comes up.
|
|
9847
9949
|
const oldPid = readHarperPid(dataDir);
|
|
9950
|
+
// unload stops the job AND prevents KeepAlive from respawning it.
|
|
9951
|
+
// launchctl stop alone is insufficient for a KeepAlive job (flair#874).
|
|
9848
9952
|
try {
|
|
9849
|
-
execSync(`launchctl
|
|
9953
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
9850
9954
|
}
|
|
9851
9955
|
catch { }
|
|
9852
9956
|
if (oldPid)
|
|
@@ -10100,7 +10204,11 @@ program
|
|
|
10100
10204
|
.option("--purge", "Also remove data and keys (destructive)")
|
|
10101
10205
|
.action(async (opts) => {
|
|
10102
10206
|
const platform = process.platform;
|
|
10103
|
-
|
|
10207
|
+
// Use the unified resolver: Harper's config > per-user config > default.
|
|
10208
|
+
// A default of 19926 that is "present but wrong" beats the actual port
|
|
10209
|
+
// Harper is serving on (flair#819). resolveHttpPort reads Harper's own
|
|
10210
|
+
// config in the data directory, which is authoritative.
|
|
10211
|
+
const port = resolveHttpPort({}, "address");
|
|
10104
10212
|
// Stop first: remove launchd service(s) on macOS, then kill by port on
|
|
10105
10213
|
// all platforms. Removes BOTH the new instance-scoped plist and a
|
|
10106
10214
|
// pre-flair#693 legacy plist if present — uninstall's job is to purge
|
|
@@ -10127,51 +10235,88 @@ program
|
|
|
10127
10235
|
// Kill any process still on the port (covers direct-start, no-service, or
|
|
10128
10236
|
// failed unload). Listening sockets only, never our own PID — see
|
|
10129
10237
|
// parseListeningPids (flair#800/flair#905).
|
|
10238
|
+
//
|
|
10239
|
+
// Guard (flair#917): refuse to SIGTERM a PID that cannot be attributed to
|
|
10240
|
+
// this Flair instance. A port is not an identity — something else can hold
|
|
10241
|
+
// it. Killing the wrong PID and then purging data is the whole bug.
|
|
10242
|
+
let refusedKill = false;
|
|
10130
10243
|
try {
|
|
10131
10244
|
const { execSync } = await import("node:child_process");
|
|
10132
10245
|
const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
10133
10246
|
if (pids.length > 0) {
|
|
10134
|
-
|
|
10135
|
-
|
|
10136
|
-
|
|
10247
|
+
// Verify ownership before killing: the PID must match this instance's
|
|
10248
|
+
// recorded PID (hdb.pid). If no PID file exists, Harper is already
|
|
10249
|
+
// stopped and the port is stale — safe to skip.
|
|
10250
|
+
const dataDir = defaultDataDir();
|
|
10251
|
+
const harperPid = readHarperPid(dataDir);
|
|
10252
|
+
if (harperPid !== null) {
|
|
10253
|
+
// PID file exists — the PID on the port must be Harper or we refuse.
|
|
10254
|
+
if (!pids.includes(harperPid)) {
|
|
10255
|
+
console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
10256
|
+
+ `do not match this Flair instance (PID ${harperPid}). `
|
|
10257
|
+
+ `Not killing — cannot attribute the process to this instance. `
|
|
10258
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
10259
|
+
refusedKill = true;
|
|
10260
|
+
}
|
|
10261
|
+
else {
|
|
10262
|
+
for (const pid of pids) {
|
|
10263
|
+
try {
|
|
10264
|
+
process.kill(pid, "SIGTERM");
|
|
10265
|
+
}
|
|
10266
|
+
catch { }
|
|
10267
|
+
}
|
|
10268
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
10269
|
+
console.log("✅ Flair process stopped");
|
|
10137
10270
|
}
|
|
10138
|
-
catch { }
|
|
10139
10271
|
}
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10272
|
+
else {
|
|
10273
|
+
// No PID file — Harper is not (or was not) running here.
|
|
10274
|
+
// The port may be stale or held by something else; don't risk killing it.
|
|
10275
|
+
console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
10276
|
+
+ `but no PID file in data directory — not a running Flair instance. `
|
|
10277
|
+
+ `Not killing — stop the process manually if it is not Flair.`);
|
|
10278
|
+
refusedKill = true;
|
|
10279
|
+
}
|
|
10143
10280
|
}
|
|
10144
10281
|
}
|
|
10145
10282
|
catch { /* not running */ }
|
|
10146
|
-
//
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10283
|
+
// Always remove per-user config on uninstall.
|
|
10284
|
+
{
|
|
10285
|
+
const cfgPath = configPath();
|
|
10286
|
+
if (existsSync(cfgPath)) {
|
|
10287
|
+
const { unlinkSync } = await import("node:fs");
|
|
10288
|
+
unlinkSync(cfgPath);
|
|
10289
|
+
console.log("✅ Config removed");
|
|
10290
|
+
}
|
|
10152
10291
|
}
|
|
10153
10292
|
if (opts.purge) {
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
const flairDir = join(homedir(), ".flair");
|
|
10158
|
-
if (existsSync(dataDir)) {
|
|
10159
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
10160
|
-
console.log("✅ Data removed: " + dataDir);
|
|
10293
|
+
if (refusedKill) {
|
|
10294
|
+
console.log("\n⚠️ Skipping purge: could not attribute the process on port — data preserved.");
|
|
10295
|
+
console.log("Stop the process manually, then re-run: flair uninstall --purge");
|
|
10161
10296
|
}
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10297
|
+
else {
|
|
10298
|
+
const { rmSync } = await import("node:fs");
|
|
10299
|
+
const dataDir = defaultDataDir();
|
|
10300
|
+
const keysDir = defaultKeysDir();
|
|
10301
|
+
const flairDir = join(homedir(), ".flair");
|
|
10302
|
+
if (existsSync(dataDir)) {
|
|
10303
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
10304
|
+
console.log("✅ Data removed: " + dataDir);
|
|
10305
|
+
}
|
|
10306
|
+
if (existsSync(keysDir)) {
|
|
10307
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
10308
|
+
console.log("✅ Keys removed: " + keysDir);
|
|
10309
|
+
}
|
|
10310
|
+
// Remove .flair dir if empty
|
|
10311
|
+
try {
|
|
10312
|
+
const { readdirSync, rmdirSync } = await import("node:fs");
|
|
10313
|
+
if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
|
|
10314
|
+
rmdirSync(flairDir);
|
|
10315
|
+
}
|
|
10171
10316
|
}
|
|
10317
|
+
catch { /* non-empty, that's fine */ }
|
|
10318
|
+
console.log("\n🗑️ Flair fully purged");
|
|
10172
10319
|
}
|
|
10173
|
-
catch { /* non-empty, that's fine */ }
|
|
10174
|
-
console.log("\n🗑️ Flair fully purged");
|
|
10175
10320
|
}
|
|
10176
10321
|
else {
|
|
10177
10322
|
console.log("\nData and keys preserved at ~/.flair/");
|
|
@@ -10821,17 +10966,24 @@ program
|
|
|
10821
10966
|
else if (versionCheckResult.latest) {
|
|
10822
10967
|
console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
|
|
10823
10968
|
}
|
|
10824
|
-
// Helper: try to reach Harper on a given port
|
|
10969
|
+
// Helper: try to reach Harper on a given port.
|
|
10970
|
+
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
|
10971
|
+
// A generic HTTP status > 0 (flair#862) would accept 404 from a Node
|
|
10972
|
+
// inspector on 9229 or any other service — "present but wrong" beats
|
|
10973
|
+
// "absent but correct".
|
|
10825
10974
|
async function probePort(p) {
|
|
10826
10975
|
try {
|
|
10827
10976
|
const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
|
|
10828
|
-
return res.
|
|
10977
|
+
return res.ok; // 200-299 only — /Health returns { ok: true } on 200
|
|
10829
10978
|
}
|
|
10830
10979
|
catch {
|
|
10831
10980
|
return false;
|
|
10832
10981
|
}
|
|
10833
10982
|
}
|
|
10834
|
-
// Helper: discover what port a Harper PID is listening on
|
|
10983
|
+
// Helper: discover what port a Harper PID is listening on.
|
|
10984
|
+
// Scans ALL listening ports for this PID and returns the first one that
|
|
10985
|
+
// responds to /Health with 200 OK. This avoids picking a debug port (9229)
|
|
10986
|
+
// or any non-Flair listener that happens to share the process (flair#862).
|
|
10835
10987
|
async function discoverPortFromPid(pid) {
|
|
10836
10988
|
// Defense-in-depth: caller already validates, but re-check here
|
|
10837
10989
|
if (!/^\d+$/.test(pid))
|
|
@@ -10839,9 +10991,16 @@ program
|
|
|
10839
10991
|
try {
|
|
10840
10992
|
const { execSync } = await import("node:child_process");
|
|
10841
10993
|
const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10994
|
+
// Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
|
|
10995
|
+
const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
|
|
10996
|
+
if (ports.length === 0)
|
|
10997
|
+
return null;
|
|
10998
|
+
// Try each port until one responds to /Health with 200 OK
|
|
10999
|
+
for (const port of ports) {
|
|
11000
|
+
if (await probePort(port))
|
|
11001
|
+
return port;
|
|
11002
|
+
}
|
|
11003
|
+
return null; // No port responded to /Health
|
|
10845
11004
|
}
|
|
10846
11005
|
catch { /* ignore */ }
|
|
10847
11006
|
return null;
|
|
@@ -12627,7 +12786,7 @@ memory.command("add [content]")
|
|
|
12627
12786
|
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
12628
12787
|
.requiredOption("--agent <id>")
|
|
12629
12788
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
12630
|
-
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
12789
|
+
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
|
|
12631
12790
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
12632
12791
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
12633
12792
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
@@ -12648,8 +12807,21 @@ memory.command("add [content]")
|
|
|
12648
12807
|
body.summary = opts.summary;
|
|
12649
12808
|
if (opts.subject)
|
|
12650
12809
|
body.subject = opts.subject;
|
|
12651
|
-
|
|
12652
|
-
|
|
12810
|
+
// flair#991: reject an unrecognized --visibility instead of writing it.
|
|
12811
|
+
// `visibility` is a free-form String server-side and the read scope asks
|
|
12812
|
+
// isPrivateVisibility() — an exact match on the literal "private" — so
|
|
12813
|
+
// ANY other string, `--visibility prvate` included, persists a row the
|
|
12814
|
+
// user believes is owner-only and that every agent on the instance can
|
|
12815
|
+
// in fact read. A typo must never widen who can read a memory.
|
|
12816
|
+
if (opts.visibility) {
|
|
12817
|
+
const visibility = String(opts.visibility).trim();
|
|
12818
|
+
if (visibility !== "private" && visibility !== "shared") {
|
|
12819
|
+
console.error(`error: --visibility must be 'private' or 'shared' (got: ${visibility})`);
|
|
12820
|
+
console.error(" omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private");
|
|
12821
|
+
process.exit(1);
|
|
12822
|
+
}
|
|
12823
|
+
body.visibility = visibility;
|
|
12824
|
+
}
|
|
12653
12825
|
if (opts.derivedFrom) {
|
|
12654
12826
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
12655
12827
|
}
|
|
@@ -13027,6 +13199,59 @@ function parseRelativeOrIso(input) {
|
|
|
13027
13199
|
const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
13028
13200
|
return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
|
|
13029
13201
|
}
|
|
13202
|
+
export function searchScoringFormula(scoring) {
|
|
13203
|
+
return scoring === "composite"
|
|
13204
|
+
? "semantic × durability-weight × recency-decay × usage-boost"
|
|
13205
|
+
: "cosine similarity only";
|
|
13206
|
+
}
|
|
13207
|
+
export function buildSearchExplain(record, scoring, now = Date.now()) {
|
|
13208
|
+
const score = typeof record?._score === "number" ? record._score : undefined;
|
|
13209
|
+
const rawScore = typeof record?._rawScore === "number" ? record._rawScore : undefined;
|
|
13210
|
+
// composite mode: server sends both (_rawScore = pre-composite semantic).
|
|
13211
|
+
// raw mode: server sends only _score, and that IS the raw score.
|
|
13212
|
+
const raw = scoring === "composite" ? rawScore : score;
|
|
13213
|
+
const composite = scoring === "composite" ? score : undefined;
|
|
13214
|
+
let ageDays;
|
|
13215
|
+
if (record?.createdAt) {
|
|
13216
|
+
const created = new Date(String(record.createdAt)).getTime();
|
|
13217
|
+
if (Number.isFinite(created))
|
|
13218
|
+
ageDays = Math.max(0, Math.floor((now - created) / 86_400_000));
|
|
13219
|
+
}
|
|
13220
|
+
const explain = {
|
|
13221
|
+
scoring,
|
|
13222
|
+
formula: searchScoringFormula(scoring),
|
|
13223
|
+
durability: record?.durability ?? "standard",
|
|
13224
|
+
usageCount: typeof record?.usageCount === "number" ? record.usageCount : 0,
|
|
13225
|
+
};
|
|
13226
|
+
if (typeof raw === "number")
|
|
13227
|
+
explain.raw = raw;
|
|
13228
|
+
if (typeof composite === "number")
|
|
13229
|
+
explain.composite = composite;
|
|
13230
|
+
if (typeof ageDays === "number")
|
|
13231
|
+
explain.ageDays = ageDays;
|
|
13232
|
+
return explain;
|
|
13233
|
+
}
|
|
13234
|
+
// Human one-liner for a hit's breakdown. Scoring terms come from the shared
|
|
13235
|
+
// builder; the trailing tags/subject/supersedes are record context that json
|
|
13236
|
+
// mode already carries at top level, so they're appended here only.
|
|
13237
|
+
export function formatSearchExplain(explain, record) {
|
|
13238
|
+
const parts = [];
|
|
13239
|
+
if (typeof explain.raw === "number")
|
|
13240
|
+
parts.push(`raw=${explain.raw.toFixed(3)}`);
|
|
13241
|
+
if (typeof explain.composite === "number")
|
|
13242
|
+
parts.push(`composite=${explain.composite.toFixed(3)}`);
|
|
13243
|
+
parts.push(`durability=${explain.durability}`);
|
|
13244
|
+
if (typeof explain.ageDays === "number")
|
|
13245
|
+
parts.push(`age=${explain.ageDays}d`);
|
|
13246
|
+
parts.push(`usage=${explain.usageCount}`);
|
|
13247
|
+
if (Array.isArray(record?.tags) && record.tags.length > 0)
|
|
13248
|
+
parts.push(`tags=[${record.tags.join(",")}]`);
|
|
13249
|
+
if (record?.subject)
|
|
13250
|
+
parts.push(`subject=${record.subject}`);
|
|
13251
|
+
if (record?.supersedes)
|
|
13252
|
+
parts.push(`supersedes=${record.supersedes}`);
|
|
13253
|
+
return parts.join(" · ");
|
|
13254
|
+
}
|
|
13030
13255
|
program
|
|
13031
13256
|
.command("search <query>")
|
|
13032
13257
|
.description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
|
|
@@ -13049,7 +13274,7 @@ program
|
|
|
13049
13274
|
.option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
|
|
13050
13275
|
.option("--source <name>", "Filter by source/agentId (client-side)")
|
|
13051
13276
|
// Output modes
|
|
13052
|
-
.option("--explain", "Show score breakdown (
|
|
13277
|
+
.option("--explain", "Show score breakdown (raw, composite, durability, age, usage) per hit — also added to --json output as _explain")
|
|
13053
13278
|
.option("--json", "Output raw JSON array")
|
|
13054
13279
|
.action(async (query, opts) => {
|
|
13055
13280
|
try {
|
|
@@ -13109,8 +13334,17 @@ program
|
|
|
13109
13334
|
results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
|
|
13110
13335
|
}
|
|
13111
13336
|
const mode = render.resolveOutputMode(opts);
|
|
13337
|
+
const scoringMode = payload.scoring === "composite" ? "composite" : "raw";
|
|
13112
13338
|
if (mode === "json") {
|
|
13113
|
-
|
|
13339
|
+
// flair#992: --explain must be honoured here, not silently dropped.
|
|
13340
|
+
// This branch is what every non-TTY caller lands in. The breakdown
|
|
13341
|
+
// rides ALONG the json contract as an opt-in `_explain` key — present
|
|
13342
|
+
// only when the caller typed --explain, so default output is unchanged
|
|
13343
|
+
// — rather than switching output mode behind the caller's back.
|
|
13344
|
+
const out = opts.explain
|
|
13345
|
+
? results.map((r) => ({ ...r, _explain: buildSearchExplain(r, scoringMode) }))
|
|
13346
|
+
: results;
|
|
13347
|
+
console.log(render.asJSON(out));
|
|
13114
13348
|
return;
|
|
13115
13349
|
}
|
|
13116
13350
|
if (results.length === 0) {
|
|
@@ -13162,30 +13396,15 @@ program
|
|
|
13162
13396
|
if (meta)
|
|
13163
13397
|
console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
|
|
13164
13398
|
if (opts.explain) {
|
|
13165
|
-
const
|
|
13166
|
-
if (
|
|
13167
|
-
|
|
13168
|
-
if (typeof r._score === "number")
|
|
13169
|
-
parts.push(`composite=${r._score.toFixed(3)}`);
|
|
13170
|
-
if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
|
|
13171
|
-
parts.push(`retrievals=${r.retrievalCount}`);
|
|
13172
|
-
if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
|
|
13173
|
-
parts.push(`tags=[${r.tags.join(",")}]`);
|
|
13174
|
-
if (r.subject)
|
|
13175
|
-
parts.push(`subject=${r.subject}`);
|
|
13176
|
-
if (r.supersedes)
|
|
13177
|
-
parts.push(`supersedes=${r.supersedes}`);
|
|
13178
|
-
if (parts.length > 0) {
|
|
13179
|
-
console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
|
|
13399
|
+
const line = formatSearchExplain(buildSearchExplain(r, scoringMode), r);
|
|
13400
|
+
if (line) {
|
|
13401
|
+
console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, line)}`);
|
|
13180
13402
|
}
|
|
13181
13403
|
}
|
|
13182
13404
|
console.log();
|
|
13183
13405
|
}
|
|
13184
13406
|
if (opts.explain) {
|
|
13185
|
-
|
|
13186
|
-
? "semantic × durability-weight × recency-decay × retrieval-boost"
|
|
13187
|
-
: "cosine similarity only";
|
|
13188
|
-
console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
|
|
13407
|
+
console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, scoringMode)} ${render.wrap(render.c.dim, `(${searchScoringFormula(scoringMode)})`)}`);
|
|
13189
13408
|
}
|
|
13190
13409
|
}
|
|
13191
13410
|
catch (err) {
|
|
@@ -13333,7 +13552,7 @@ soul.command("set")
|
|
|
13333
13552
|
.requiredOption("--agent <id>")
|
|
13334
13553
|
.requiredOption("--key <key>")
|
|
13335
13554
|
.requiredOption("--value <value>")
|
|
13336
|
-
.option("--durability <d>", "permanent")
|
|
13555
|
+
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
|
|
13337
13556
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
13338
13557
|
.action(async (opts) => {
|
|
13339
13558
|
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
@@ -14068,6 +14287,19 @@ program
|
|
|
14068
14287
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|
|
14069
14288
|
process.exit(1);
|
|
14070
14289
|
}
|
|
14290
|
+
// flair#968: `flair backup > file.json` captures the progress report, not
|
|
14291
|
+
// the archive (which goes to --output, defaulting to ~/.flair/backups/...).
|
|
14292
|
+
// The result was exit 0 and a plausible-looking file of a few hundred bytes —
|
|
14293
|
+
// a false success immediately before a destructive upgrade.
|
|
14294
|
+
//
|
|
14295
|
+
// When stdout is not a TTY, route progress output to stderr. The archive
|
|
14296
|
+
// still goes to --output / the default path. This makes `flair backup >
|
|
14297
|
+
// file.json` produce an EMPTY file — unmistakably not a valid archive —
|
|
14298
|
+
// while leaving default-path callers (schedulers, cron) completely
|
|
14299
|
+
// unaffected.
|
|
14300
|
+
const log = process.stdout.isTTY
|
|
14301
|
+
? console.log.bind(console)
|
|
14302
|
+
: console.error.bind(console);
|
|
14071
14303
|
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
14072
14304
|
async function adminGet(path) {
|
|
14073
14305
|
const res = await fetch(`${baseUrl}${path}`, {
|
|
@@ -14080,11 +14312,11 @@ program
|
|
|
14080
14312
|
}
|
|
14081
14313
|
return res.json();
|
|
14082
14314
|
}
|
|
14083
|
-
|
|
14315
|
+
log("Fetching agents...");
|
|
14084
14316
|
const allAgents = await adminGet("/Agent/");
|
|
14085
14317
|
const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
|
|
14086
14318
|
const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
|
|
14087
|
-
|
|
14319
|
+
log(`Fetching memories for ${agents.length} agent(s)...`);
|
|
14088
14320
|
const memories = [];
|
|
14089
14321
|
for (const agent of agents) {
|
|
14090
14322
|
try {
|
|
@@ -14096,7 +14328,7 @@ program
|
|
|
14096
14328
|
console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
|
|
14097
14329
|
}
|
|
14098
14330
|
}
|
|
14099
|
-
|
|
14331
|
+
log("Fetching souls...");
|
|
14100
14332
|
const souls = [];
|
|
14101
14333
|
for (const agent of agents) {
|
|
14102
14334
|
try {
|
|
@@ -14124,11 +14356,11 @@ program
|
|
|
14124
14356
|
const tmp = outputPath + ".tmp";
|
|
14125
14357
|
writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
|
|
14126
14358
|
renameSync(tmp, outputPath);
|
|
14127
|
-
|
|
14128
|
-
|
|
14129
|
-
|
|
14130
|
-
|
|
14131
|
-
|
|
14359
|
+
log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
|
|
14360
|
+
log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
|
|
14361
|
+
log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
|
|
14362
|
+
log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
|
|
14363
|
+
log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
|
|
14132
14364
|
});
|
|
14133
14365
|
// ─── flair restore ────────────────────────────────────────────────────────────
|
|
14134
14366
|
program
|
|
@@ -15137,4 +15369,4 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
|
|
|
15137
15369
|
// Harper's own config — the per-instance port record (flair#914)
|
|
15138
15370
|
harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
|
|
15139
15371
|
// launchd label (flair#693)
|
|
15140
|
-
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
|
|
15372
|
+
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
|