@tpsdev-ai/flair 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,19 @@ Self-hosted on [Harper](https://harper.fast) as a single process. No sidecars, n
40
40
 
41
41
  ## Quick start
42
42
 
43
- Needs **Node.js 22+** and a **user-writable npm global prefix**.
43
+ Two front doors. Pick by where your code already runs.
44
+
45
+ ### Already on Harper? Load Flair as a component
46
+
47
+ Flair *is* a Harper component. Deploy it into the instance your application already runs in and call its resources directly — `await h.post({ agentId, content })` is a **method call**, not a network call. No second service to operate, no HTTP round trip, and no key to distribute: a caller in the same process is already inside the trust boundary and names the agent it is acting as, per call.
48
+
49
+ Adding it takes nothing away. The HTTP surface keeps serving MCP clients and remote agents exactly as before.
50
+
51
+ **→ [Embedding Flair in a Harper app](docs/embedding-in-a-harper-app.md)** — the whole in-process contract, measured against a real instance: resolving the resource, the table-vs-resource distinction that decides whether your memories are scoped at all, N agents in one process, and registering agents with no shell on the node.
52
+
53
+ ### Everywhere else — install the CLI
54
+
55
+ A laptop, a VPS, an MCP client, or any language over HTTP. Needs **Node.js 22+** and a **user-writable npm global prefix**.
44
56
 
45
57
  > ⚠️ **Never `sudo npm install -g @tpsdev-ai/flair`.** A root-owned install can't write the embedding model into its own package directory, so semantic search silently degrades to keyword-only. `flair init` and `flair doctor` will warn you loudly. Use `nvm`, or point npm at your home directory: `npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`.
46
58
 
@@ -73,6 +85,14 @@ That trailing figure is a rank score, normalized so the top hit is always near 1
73
85
 
74
86
  Full walkthrough with expected output at every step: **[docs/quickstart.md](docs/quickstart.md)**.
75
87
 
88
+ ### Where the agent's key lives
89
+
90
+ `flair init --agent mybot` writes the private key to `~/.flair/keys/mybot.key` (mode `0600`) and the public half beside it as `mybot.pub`. Only the **public** key is registered on the instance; the private key never leaves the machine. `--keys-dir` writes both somewhere else.
91
+
92
+ **Back that file up — it is the agent's identity, and there is one copy.** Memories are not encrypted with it, so losing it costs the identity, not the data: the agent can no longer sign, and every HTTP call it makes fails. Recovery is `flair agent rotate-key mybot`, which mints a new pair and re-registers the public half — it needs the admin password `flair init` wrote to `~/.flair/admin-pass`, so back that up too. Otherwise treat the key like an SSH key: one per agent per host, never copied between machines ([docs/secrets-and-keys.md](docs/secrets-and-keys.md)).
93
+
94
+ **The in-process path needs no key at all.** Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance asserts identity through the call context instead — `agentContext("mybot")` — which Flair reads and acts on with no signature, no `Agent`-table lookup and no registration. That is deliberate: same-process code could write the storage tables directly, so demanding a signature from it would be theatre. It is also why that id must come from your own server-side state and never from request data.
95
+
76
96
  ### Useful flags
77
97
 
78
98
  ```bash
@@ -278,24 +298,30 @@ Sign `agentId:timestamp:nonce:METHOD:/path` with the agent's private key. Protoc
278
298
 
279
299
  ### Embedded in a Harper app (in-process)
280
300
 
281
- Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip.
301
+ Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip, and no key anywhere.
282
302
 
283
303
  ```javascript
284
304
  import { server } from "harper";
305
+ import { agentContext, collectionResource } from "@tpsdev-ai/flair/dist/resources/in-process.js";
306
+
307
+ // The RESOURCE — auth, read-scoping, visibility, embedding.
308
+ // Registry keys carry no leading slash: get("Memory"), never get("/Memory").
309
+ const Memory = server.resources.get("Memory").Resource;
285
310
 
286
- const Memory = server.resources.get("Memory").Resource; // the resource, not the table
287
- const h = new Memory(undefined, { request: { tpsAgent: "mybot" } });
311
+ const h = await collectionResource(Memory, agentContext("mybot"));
288
312
  await h.post({ agentId: "mybot", content: "...", durability: "standard" });
289
313
  ```
290
314
 
291
- `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. A context-less call runs unfiltered. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
315
+ `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. `new Memory(...)` is not a substitute for `collectionResource` — a create needs a collection-bound instance only Harper can produce. Both helpers refuse a missing agent id rather than defaulting it, because a resource invoked with no context resolves to Flair's trusted `internal` verdict and runs unfiltered across every agent. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
292
316
 
293
317
  ### Auth across surfaces
294
318
 
295
- The default everywhere is **Ed25519 per-agent**: each agent holds its own key at `~/.flair/keys/<agent>.key` and signs every request. That gives write isolation — no agent can write as another — and identity-verified reads. It does *not* refuse cross-agent reads: within one instance, any verified agent can read any other agent's non-private memory by design. The hard boundary is the federation edge, not intra-instance reads. See [SECURITY.md](SECURITY.md).
319
+ For every caller that reaches Flair over the network the default is **Ed25519 per-agent**: each agent holds its own key at `~/.flair/keys/<agent>.key` and signs every request. That gives write isolation — no agent can write as another — and identity-verified reads. It does *not* refuse cross-agent reads: within one instance, any verified agent can read any other agent's non-private memory by design. The hard boundary is the federation edge, not intra-instance reads. See [SECURITY.md](SECURITY.md).
296
320
 
297
321
  One exception: the **`n8n-nodes-flair`** node authenticates with the Harper **admin password** (Basic auth), which bypasses agent scoping entirely — it can read other agents' `visibility: private` memories and write as anyone. That is acceptable only on a single-tenant, operator-controlled n8n with trusted workflow inputs. Otherwise prefer the Ed25519 path. Full breakdown in **[docs/auth.md](docs/auth.md#auth-across-surfaces-read-this-first)**.
298
322
 
323
+ In-process callers are a different model, not an exception to this one: they never sign, because identity is asserted through the call context rather than proven. Co-location *is* the grant — which is why Flair beside untrusted co-tenants on a shared instance is a different proposition to Flair inside your own app.
324
+
299
325
  ## Deployment
300
326
 
301
327
  ### Local (default)
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
- const newContent = legacyContent.replace(`<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`, `<key>Label</key><string>${newLabel}</string>`);
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 migration: a pre-flair#693 install registered under
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
- const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
2943
- if (existsSync(legacyPlistPath)) {
2944
- try {
2945
- const { execSync } = await import("node:child_process");
2946
- execSync(`launchctl unload "${legacyPlistPath}"`, { stdio: "pipe" });
2947
- }
2948
- catch { /* best effort */ }
2949
- try {
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.
@@ -9741,20 +9807,28 @@ program
9741
9807
  * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9742
9808
  * extracted ROOTPATH path ever reaches a message.
9743
9809
  */
9744
- export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9745
- let declared = null;
9810
+ /**
9811
+ * Read the ROOTPATH declared in a launchd plist, or null if it cannot be
9812
+ * determined (file missing, unreadable, or no ROOTPATH key).
9813
+ *
9814
+ * The plist stores this XML-escaped (buildLaunchdPlist), so the returned
9815
+ * value is decoded through unescapeXml before being returned — a data dir
9816
+ * containing `&` is on disk as `&amp;` and this returns the literal `&`.
9817
+ *
9818
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD.
9819
+ */
9820
+ export function readPlistRootPath(plistPath) {
9746
9821
  try {
9747
9822
  const raw = readFileSync(plistPath, "utf-8");
9748
9823
  const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9749
- // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9750
- // containing `&` is on disk as `&amp;`. 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;
9824
+ return m ? unescapeXml(m[1]) : null;
9754
9825
  }
9755
9826
  catch {
9756
- return; // unreadable — no evidence, don't block
9827
+ return null;
9757
9828
  }
9829
+ }
9830
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9831
+ const declared = readPlistRootPath(plistPath);
9758
9832
  if (declared === null)
9759
9833
  return;
9760
9834
  if (resolve(declared) === resolve(dataDir))
@@ -9835,18 +9909,15 @@ async function stopFlairProcess(port, dataDir) {
9835
9909
  assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
9836
9910
  try {
9837
9911
  const { execSync } = await import("node:child_process");
9838
- // Ensure the service is loaded (init writes the plist but doesn't load it)
9839
- try {
9840
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
9841
- }
9842
- catch { }
9843
- // Capture the current PID *before* stopping so callers that
9912
+ // Capture the current PID *before* unloading so callers that
9844
9913
  // immediately restart can verify exit. Without this, waitForHealth
9845
9914
  // can race against the still-shutting-down old process and return
9846
- // success before KeepAlive brings the new one up.
9915
+ // success before the new one comes up.
9847
9916
  const oldPid = readHarperPid(dataDir);
9917
+ // unload stops the job AND prevents KeepAlive from respawning it.
9918
+ // launchctl stop alone is insufficient for a KeepAlive job (flair#874).
9848
9919
  try {
9849
- execSync(`launchctl stop ${label}`, { stdio: "pipe" });
9920
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
9850
9921
  }
9851
9922
  catch { }
9852
9923
  if (oldPid)
@@ -14068,6 +14139,19 @@ program
14068
14139
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
14069
14140
  process.exit(1);
14070
14141
  }
14142
+ // flair#968: `flair backup > file.json` captures the progress report, not
14143
+ // the archive (which goes to --output, defaulting to ~/.flair/backups/...).
14144
+ // The result was exit 0 and a plausible-looking file of a few hundred bytes —
14145
+ // a false success immediately before a destructive upgrade.
14146
+ //
14147
+ // When stdout is not a TTY, route progress output to stderr. The archive
14148
+ // still goes to --output / the default path. This makes `flair backup >
14149
+ // file.json` produce an EMPTY file — unmistakably not a valid archive —
14150
+ // while leaving default-path callers (schedulers, cron) completely
14151
+ // unaffected.
14152
+ const log = process.stdout.isTTY
14153
+ ? console.log.bind(console)
14154
+ : console.error.bind(console);
14071
14155
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
14072
14156
  async function adminGet(path) {
14073
14157
  const res = await fetch(`${baseUrl}${path}`, {
@@ -14080,11 +14164,11 @@ program
14080
14164
  }
14081
14165
  return res.json();
14082
14166
  }
14083
- console.log("Fetching agents...");
14167
+ log("Fetching agents...");
14084
14168
  const allAgents = await adminGet("/Agent/");
14085
14169
  const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
14086
14170
  const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
14087
- console.log(`Fetching memories for ${agents.length} agent(s)...`);
14171
+ log(`Fetching memories for ${agents.length} agent(s)...`);
14088
14172
  const memories = [];
14089
14173
  for (const agent of agents) {
14090
14174
  try {
@@ -14096,7 +14180,7 @@ program
14096
14180
  console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
14097
14181
  }
14098
14182
  }
14099
- console.log("Fetching souls...");
14183
+ log("Fetching souls...");
14100
14184
  const souls = [];
14101
14185
  for (const agent of agents) {
14102
14186
  try {
@@ -14124,11 +14208,11 @@ program
14124
14208
  const tmp = outputPath + ".tmp";
14125
14209
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
14126
14210
  renameSync(tmp, outputPath);
14127
- console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14128
- console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14129
- console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14130
- console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14131
- console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14211
+ log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14212
+ log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14213
+ log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14214
+ log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14215
+ log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14132
14216
  });
14133
14217
  // ─── flair restore ────────────────────────────────────────────────────────────
14134
14218
  program
@@ -15137,4 +15221,4 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
15137
15221
  // Harper's own config — the per-instance port record (flair#914)
15138
15222
  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
15223
  // launchd label (flair#693)
15140
- LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
15224
+ LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };