@tpsdev-ai/flair 0.44.3 → 0.44.5

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/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMc
26
26
  import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
27
27
  import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
28
28
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
29
+ import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
29
30
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
30
31
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
31
32
  import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
@@ -638,10 +639,47 @@ function resolveBaseUrl(opts) {
638
639
  || process.env.FLAIR_URL
639
640
  || `http://127.0.0.1:${resolveHttpPort(opts)}`);
640
641
  }
641
- // Resolve agent id from --agent flag or FLAIR_AGENT_ID env.
642
- // Returns null if neither is set; caller decides whether that's fatal.
642
+ // Resolve agent id from --agent flag or FLAIR_AGENT_ID env (flag > env).
643
+ // The low-level helper with no debug line used where only the flag/env pair
644
+ // is wanted (e.g. the upgrade path that then applies its own key-dir floor).
645
+ // Delegates to the canonical core so the flag>env precedence lives in exactly
646
+ // one place (flair#1183). Returns null if neither is set; caller decides
647
+ // whether that's fatal.
643
648
  function resolveAgentIdOrEnv(opts) {
644
- return opts.agent || process.env.FLAIR_AGENT_ID || null;
649
+ return resolveSigningIdentity(opts).agentId;
650
+ }
651
+ // ── The ONE signing-identity seam every user-facing command family calls ──────
652
+ //
653
+ // Precedence (flair#1183): --agent flag > FLAIR_AGENT_ID env > config profile.
654
+ //
655
+ // This seam pins the top two tiers — the flag and the env — and resolves the
656
+ // signer ONCE, at the command boundary, threading the result down to
657
+ // api()/authedRequest as an AUTHORITATIVE value. That is the whole fix: a lower
658
+ // layer must never re-derive the signer from the environment behind the
659
+ // command's back (api() used to, signing as FLAIR_AGENT_ID even when --agent
660
+ // named someone else).
661
+ //
662
+ // The third tier — the "config profile" — is the machine's ambient credential
663
+ // (the ~/.flair/admin-pass file and the Ed25519 agent-key FLOOR), applied BELOW
664
+ // this by authedRequest (src/lib/auth-resolve.ts tiers 4-5) when this seam
665
+ // resolves nothing. It is deliberately NOT a name lookup here: making a
666
+ // forgotten --agent silently resolve to some configured identity is the exact
667
+ // silent-substitution this issue is about, and it would also turn every
668
+ // "identity required" command into one that guesses. So when neither flag nor
669
+ // env is set this returns null, and the caller either demands an explicit
670
+ // identity (agent-scoped commands) or lets authedRequest apply the ambient
671
+ // config-profile credential. Emits the FLAIR_DEBUG line naming the resolved
672
+ // agentId + which source won, so operator, CLI, and server cannot silently
673
+ // disagree about who's calling.
674
+ function resolveSigningIdentityFor(opts, command) {
675
+ const resolved = resolveSigningIdentity(opts);
676
+ emitSigningIdentityDebug(resolved, command);
677
+ return resolved;
678
+ }
679
+ // Same seam, returning just the agentId (or null) for the common call site that
680
+ // only needs the id. Still emits the debug line via resolveSigningIdentityFor.
681
+ function resolveSigningAgentId(opts, command) {
682
+ return resolveSigningIdentityFor(opts, command).agentId;
645
683
  }
646
684
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
647
685
  //
@@ -1336,10 +1374,23 @@ function b64url(bytes) {
1336
1374
  * api() resolves the request's Harper HTTP/REST auth via the shared
1337
1375
  * `authedRequest` (src/lib/auth-resolve.ts — see that module's header for
1338
1376
  * the full 5-tier resolution order, including tier 5, the Ed25519 agent-key
1339
- * FLOOR this used to lack entirely). The only thing api() itself still owns
1340
- * is Harper-CLI-convention agentId extraction (FLAIR_AGENT_ID env, or an
1341
- * agentId embedded in the request body/query string) request-shape
1342
- * knowledge that belongs here, not in the generic resolver.
1377
+ * FLOOR this used to lack entirely).
1378
+ *
1379
+ * SIGNING IDENTITY (flair#1183): a caller that has already resolved the signer
1380
+ * every user-facing command does, via `resolveSigningAgentId` (flag > env >
1381
+ * config profile) — passes it as `options.agentId`, and that value is
1382
+ * AUTHORITATIVE. api() does NOT re-derive it from the environment in that case.
1383
+ * That re-derivation was the bug: api() used to compute the signer as
1384
+ * `FLAIR_AGENT_ID env > body.agentId`, so a command run with `--agent X` while
1385
+ * `FLAIR_AGENT_ID=Y` was exported signed as Y — inverting the documented
1386
+ * precedence and silently disagreeing with the record/query, which both named X.
1387
+ *
1388
+ * Only when the caller passes NO agentId (the `"agentId" in options` check
1389
+ * distinguishes an omitted key from an explicit `null`) does api() fall back to
1390
+ * the legacy request-shape extraction — FLAIR_AGENT_ID env, then an agentId
1391
+ * embedded in the body/query string. That path serves the admin/federation
1392
+ * callers that resolve no identity of their own and lean on admin-pass / the
1393
+ * floor, so their behavior is unchanged.
1343
1394
  *
1344
1395
  * NOTE: this function is for the Harper HTTP/REST API only. The Harper
1345
1396
  * operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
@@ -1355,14 +1406,24 @@ async function api(method, path, body, options) {
1355
1406
  // Resolve port via the canonical path (flair#1129): options.baseUrl > FLAIR_URL > resolveHttpPort.
1356
1407
  // api() callers mean the default install, so resolveHttpPort({}) with no --data-dir is correct.
1357
1408
  const base = options?.baseUrl ?? (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`);
1358
- // Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
1359
- // query params (GET) Harper-CLI-request-shape knowledge, not a generic
1360
- // auth concern, so it stays here rather than in authedRequest.
1361
- let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
1362
- if (!agentId && path.includes("agentId=")) {
1363
- const match = path.match(/agentId=([^&]+)/);
1364
- if (match)
1365
- agentId = decodeURIComponent(match[1]);
1409
+ let agentId;
1410
+ if (options && "agentId" in options) {
1411
+ // The caller resolved the signing identity (flag > env > config profile)
1412
+ // and it is AUTHORITATIVE never override it with FLAIR_AGENT_ID here
1413
+ // (that inversion is flair#1183). An explicit null means "no identity
1414
+ // resolved"; honor it and let authedRequest fall to admin-pass / the floor.
1415
+ agentId = options.agentId ?? undefined;
1416
+ }
1417
+ else {
1418
+ // Legacy request-shape extraction for callers that resolve no identity of
1419
+ // their own (admin/federation ops): FLAIR_AGENT_ID env, then the body
1420
+ // (POST/PUT) / URL query params (GET).
1421
+ agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
1422
+ if (!agentId && path.includes("agentId=")) {
1423
+ const match = path.match(/agentId=([^&]+)/);
1424
+ if (match)
1425
+ agentId = decodeURIComponent(match[1]);
1426
+ }
1366
1427
  }
1367
1428
  return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
1368
1429
  }
@@ -7949,7 +8010,12 @@ export async function discoverLocalFlairPort(originalUrl) {
7949
8010
  }
7950
8011
  return null;
7951
8012
  }
7952
- async function fetchHealthDetail(opts) {
8013
+ // flair#1183: `signingAgentIdOverride` lets a calling command that already
8014
+ // resolved a signing identity via the canonical seam (resolveSigningAgentId)
8015
+ // pass it in, so this verified read signs as the SAME agent the rest of the
8016
+ // command does. Undefined = resolve locally via the legacy flag>env pair (all
8017
+ // other callers, unchanged).
8018
+ async function fetchHealthDetail(opts, signingAgentIdOverride) {
7953
8019
  const port = resolveHttpPort(opts);
7954
8020
  // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
7955
8021
  const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
@@ -7982,7 +8048,9 @@ async function fetchHealthDetail(opts) {
7982
8048
  try {
7983
8049
  healthData = await authedRequest("GET", "/HealthDetail", undefined, {
7984
8050
  baseUrl,
7985
- agentId: opts.agent || process.env.FLAIR_AGENT_ID,
8051
+ agentId: signingAgentIdOverride !== undefined
8052
+ ? (signingAgentIdOverride ?? undefined)
8053
+ : (opts.agent || process.env.FLAIR_AGENT_ID),
7986
8054
  });
7987
8055
  }
7988
8056
  catch {
@@ -8001,7 +8069,8 @@ const statusCmd = program
8001
8069
  .option("--json", "Output as JSON")
8002
8070
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
8003
8071
  .action(async (opts) => {
8004
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
8072
+ const statusAgentId = resolveSigningAgentId(opts, "status");
8073
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
8005
8074
  // When unreachable on a localhost URL, probe candidate ports to detect
8006
8075
  // config-vs-daemon port drift. Surface the actually-listening
8007
8076
  // port with a fix recipe — better UX than just "unreachable."
@@ -12861,7 +12930,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
12861
12930
  let all;
12862
12931
  try {
12863
12932
  const q = new URLSearchParams({ agentId }).toString();
12864
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
12933
+ const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
12865
12934
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
12866
12935
  }
12867
12936
  catch (err) {
@@ -12888,7 +12957,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
12888
12957
  const id = String(m.id);
12889
12958
  const cue = deriveRecallCue(m);
12890
12959
  const body = { agentId, q: cue, limit: k };
12891
- const res = await api("POST", "/SemanticSearch", body, { baseUrl });
12960
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
12892
12961
  const results = Array.isArray(res) ? res : (res?.results ?? []);
12893
12962
  sampledIds.push(id);
12894
12963
  perQueryResultIds.push(results.map((r) => String(r.id)));
@@ -13103,7 +13172,7 @@ async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
13103
13172
  let all;
13104
13173
  try {
13105
13174
  const q = new URLSearchParams({ agentId }).toString();
13106
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
13175
+ const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
13107
13176
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
13108
13177
  }
13109
13178
  catch {
@@ -13146,7 +13215,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
13146
13215
  type: "quality-snapshot",
13147
13216
  createdAt: new Date().toISOString(),
13148
13217
  };
13149
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl });
13218
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
13150
13219
  if (out?.error)
13151
13220
  throw new Error(String(out.error));
13152
13221
  return memId;
@@ -13162,8 +13231,8 @@ program
13162
13231
  .option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
13163
13232
  .option("--emit", "Slice 2: snapshot this report, diff it against the previous quality-snapshot memory, and emit OrgEvents (quality.threshold_crossed / quality.regression) for any crossings/regressions found. Requires an agent identity (--agent or FLAIR_AGENT_ID) — the opt-in write boundary; without this flag `flair quality` remains fully read-only")
13164
13233
  .action(async (opts) => {
13165
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
13166
- const agentId = opts.agent || process.env.FLAIR_AGENT_ID || null;
13234
+ const agentId = resolveSigningAgentId(opts, "quality");
13235
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
13167
13236
  if (opts.emit && !agentId) {
13168
13237
  console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
13169
13238
  process.exit(1);
@@ -13549,6 +13618,7 @@ memory.command("add [content]")
13549
13618
  console.error("error: content required (positional arg or --content)");
13550
13619
  process.exit(1);
13551
13620
  }
13621
+ const agentId = resolveSigningAgentId(opts, "memory add") ?? opts.agent;
13552
13622
  const memId = `${opts.agent}-${Date.now()}`;
13553
13623
  const body = {
13554
13624
  id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
@@ -13577,7 +13647,7 @@ memory.command("add [content]")
13577
13647
  if (opts.derivedFrom) {
13578
13648
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
13579
13649
  }
13580
- const out = await api("PUT", `/Memory/${memId}`, body);
13650
+ const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
13581
13651
  console.log(JSON.stringify(out, null, 2));
13582
13652
  });
13583
13653
  // ─── flair memory write-task-summary ────────────────────────────────────────
@@ -13632,6 +13702,7 @@ memory.command("write-task-summary")
13632
13702
  lines.push(opts.summary);
13633
13703
  }
13634
13704
  const content = lines.join("\n");
13705
+ const agentId = resolveSigningAgentId(opts, "memory write-task-summary") ?? opts.agent;
13635
13706
  const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
13636
13707
  const body = {
13637
13708
  id: memId,
@@ -13648,7 +13719,7 @@ memory.command("write-task-summary")
13648
13719
  if (opts.derivedFrom) {
13649
13720
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
13650
13721
  }
13651
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body);
13722
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
13652
13723
  if (out?.error) {
13653
13724
  console.error(`Error writing task summary: ${out.error}`);
13654
13725
  process.exit(1);
@@ -13667,7 +13738,7 @@ memory.command("search [query]")
13667
13738
  .option("--url <url>", "Flair base URL (overrides --port)")
13668
13739
  .option("--port <port>", "Harper HTTP port")
13669
13740
  .action(async (queryArg, opts) => {
13670
- const agentId = resolveAgentIdOrEnv(opts);
13741
+ const agentId = resolveSigningAgentId(opts, "memory search");
13671
13742
  if (!agentId) {
13672
13743
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
13673
13744
  process.exit(2);
@@ -13681,7 +13752,7 @@ memory.command("search [query]")
13681
13752
  if (opts.tag)
13682
13753
  body.tag = opts.tag;
13683
13754
  const baseUrl = resolveBaseUrl(opts);
13684
- const res = await api("POST", "/SemanticSearch", body, { baseUrl });
13755
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
13685
13756
  console.log(JSON.stringify(res, null, 2));
13686
13757
  });
13687
13758
  memory.command("list")
@@ -13692,13 +13763,13 @@ memory.command("list")
13692
13763
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
13693
13764
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
13694
13765
  .action(async (opts) => {
13695
- const agentId = resolveAgentIdOrEnv(opts);
13766
+ const agentId = resolveSigningAgentId(opts, "memory list");
13696
13767
  if (!agentId) {
13697
13768
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
13698
13769
  process.exit(2);
13699
13770
  }
13700
13771
  const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
13701
- const raw = await api("GET", `/Memory?${q}`);
13772
+ const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
13702
13773
  const mode = render.resolveOutputMode(opts);
13703
13774
  // hashFallback flag changes the lens: instead of all memories, show
13704
13775
  // only those that need re-embedding. Keep that surface separate.
@@ -14030,7 +14101,7 @@ program
14030
14101
  .option("--json", "Output raw JSON array")
14031
14102
  .action(async (query, opts) => {
14032
14103
  try {
14033
- const agentId = resolveAgentIdOrEnv(opts);
14104
+ const agentId = resolveSigningAgentId(opts, "search");
14034
14105
  if (!agentId) {
14035
14106
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
14036
14107
  process.exit(2);
@@ -14190,7 +14261,7 @@ program
14190
14261
  .option("--key <path>", "Ed25519 private key path")
14191
14262
  .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
14192
14263
  .action(async (opts) => {
14193
- const agentId = resolveAgentIdOrEnv(opts);
14264
+ const agentId = resolveSigningAgentId(opts, "bootstrap");
14194
14265
  if (!agentId) {
14195
14266
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
14196
14267
  process.exit(2);
@@ -14279,6 +14350,7 @@ relationship.command("add")
14279
14350
  .option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
14280
14351
  .option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
14281
14352
  .action(async (opts) => {
14353
+ const agentId = resolveSigningAgentId(opts, "relationship add") ?? opts.agent;
14282
14354
  const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
14283
14355
  const body = {
14284
14356
  id,
@@ -14295,7 +14367,7 @@ relationship.command("add")
14295
14367
  body.validTo = opts.validTo;
14296
14368
  if (opts.source)
14297
14369
  body.source = opts.source;
14298
- const out = await api("PUT", `/Relationship/${id}`, body);
14370
+ const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
14299
14371
  console.log(JSON.stringify(out, null, 2));
14300
14372
  });
14301
14373
  const soul = program.command("soul").description("Manage agent soul entries");
@@ -14310,6 +14382,13 @@ soul.command("set")
14310
14382
  // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
14311
14383
  // The Soul table resource has no POST handler, so a collection POST /Soul
14312
14384
  // 405s; the record must be written by its primary key. (#498)
14385
+ //
14386
+ // flair#1183: resolve the SIGNING identity through the canonical seam and
14387
+ // thread it to api(). --agent is required, so the flag always wins the
14388
+ // precedence — but before this, api() re-derived the signer as
14389
+ // FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
14390
+ // wrote a record owned by X while signing as Y (the soul family's stale rung).
14391
+ const agentId = resolveSigningAgentId(opts, "soul set") ?? opts.agent;
14313
14392
  const id = `${opts.agent}:${opts.key}`;
14314
14393
  const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
14315
14394
  id,
@@ -14318,7 +14397,7 @@ soul.command("set")
14318
14397
  value: opts.value,
14319
14398
  durability: opts.durability,
14320
14399
  createdAt: new Date().toISOString(),
14321
- });
14400
+ }, { agentId });
14322
14401
  const mode = render.resolveOutputMode(opts);
14323
14402
  if (mode === "json") {
14324
14403
  console.log(render.asJSON(out));
@@ -14334,9 +14413,14 @@ soul.command("set")
14334
14413
  soul.command("get")
14335
14414
  .description("Fetch a single soul entry by id (agent:key)")
14336
14415
  .argument("<id>")
14416
+ .option("--agent <id>", "Agent ID to sign the read as (or set FLAIR_AGENT_ID); falls back to the config-profile agent")
14337
14417
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
14338
14418
  .action(async (id, opts) => {
14339
- const out = await api("GET", `/Soul/${id}`);
14419
+ // flair#1183: /Soul reads are verified (any registered agent). Resolve the
14420
+ // signer through the canonical seam so soul get honors the SAME precedence
14421
+ // as every other family; a null result lets api() fall to admin-pass/floor.
14422
+ const agentId = resolveSigningAgentId(opts, "soul get");
14423
+ const out = await api("GET", `/Soul/${id}`, undefined, { agentId });
14340
14424
  const mode = render.resolveOutputMode(opts);
14341
14425
  if (mode === "json") {
14342
14426
  console.log(render.asJSON(out));
@@ -14368,12 +14452,12 @@ soul.command("list")
14368
14452
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
14369
14453
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
14370
14454
  .action(async (opts) => {
14371
- const agentId = resolveAgentIdOrEnv(opts);
14455
+ const agentId = resolveSigningAgentId(opts, "soul list");
14372
14456
  if (!agentId) {
14373
14457
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
14374
14458
  process.exit(2);
14375
14459
  }
14376
- const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`);
14460
+ const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
14377
14461
  const mode = render.resolveOutputMode(opts);
14378
14462
  if (mode === "json") {
14379
14463
  console.log(render.asJSON(out));
@@ -15735,7 +15819,7 @@ presence
15735
15819
  .option("--port <port>", "Harper HTTP port")
15736
15820
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
15737
15821
  .action(async (opts) => {
15738
- const agentId = resolveAgentIdOrEnv(opts);
15822
+ const agentId = resolveSigningAgentId(opts, "presence set");
15739
15823
  if (!agentId) {
15740
15824
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
15741
15825
  process.exit(1);
@@ -15820,7 +15904,7 @@ workspace
15820
15904
  .option("--port <port>", "Harper HTTP port")
15821
15905
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
15822
15906
  .action(async (opts) => {
15823
- const agentId = resolveAgentIdOrEnv(opts);
15907
+ const agentId = resolveSigningAgentId(opts, "workspace set");
15824
15908
  if (!agentId) {
15825
15909
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
15826
15910
  process.exit(1);
@@ -15962,7 +16046,7 @@ program
15962
16046
  .option("--port <port>", "Harper HTTP port")
15963
16047
  .option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
15964
16048
  .action(async (opts) => {
15965
- const agentId = resolveAgentIdOrEnv(opts);
16049
+ const agentId = resolveSigningAgentId(opts, "orgevent");
15966
16050
  if (!agentId) {
15967
16051
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
15968
16052
  process.exit(1);
@@ -16028,7 +16112,7 @@ program
16028
16112
  .option("--json", "Output raw JSON")
16029
16113
  .action(async (entity, opts) => {
16030
16114
  try {
16031
- const agentId = resolveAgentIdOrEnv(opts);
16115
+ const agentId = resolveSigningAgentId(opts, "attention");
16032
16116
  if (!agentId) {
16033
16117
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
16034
16118
  process.exit(2);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * signing-identity.ts — the ONE canonical signing-identity resolver (flair#1183).
3
+ *
4
+ * Before this file existed, every CLI command family answered "which agent am
5
+ * I signing this request AS?" with its own chain, and they disagreed:
6
+ *
7
+ * - `search`/`bootstrap`/`status`/`presence`/`workspace` resolved
8
+ * `--agent flag > FLAIR_AGENT_ID env` (via `resolveAgentIdOrEnv`), then
9
+ * hand-signed with that id.
10
+ * - Everything routed through `api()` (memory search/list, soul list, and
11
+ * the writes) RE-derived the signer inside `api()` as
12
+ * `FLAIR_AGENT_ID env > body.agentId`. That inverts the precedence: a
13
+ * command the operator ran with `--agent X` while `FLAIR_AGENT_ID=Y` was
14
+ * exported in the shell SIGNED as Y, even though the record it wrote and
15
+ * the query it filtered both named X. Against a remote target where Y is
16
+ * not registered, the server answered `unknown_agent` — three parties
17
+ * (operator, CLI, server) silently disagreeing about who was calling.
18
+ * - The `soul` family had NO env/flag resolution of its own at all: it put
19
+ * `--agent` into the request body and leaned entirely on `api()`'s
20
+ * env-first extraction, so even the `FLAIR_KEY_DIR` workaround that forced
21
+ * the identity on the other commands could not steer it. That was the
22
+ * worst rung — the "stale rung" the issue calls out.
23
+ *
24
+ * This module makes the precedence ONE thing, documented and testable:
25
+ *
26
+ * ── Resolution order ──────────────────────────────────────────────────────
27
+ * 1. FLAG — an explicit `--agent <id>`. The operator naming an identity on
28
+ * THIS invocation always wins.
29
+ * 2. ENV — `FLAIR_AGENT_ID`. Ambient but still a deliberate operator/CI
30
+ * choice for the session.
31
+ * 3. CONFIG — the "config profile": the machine's ambient signing credential
32
+ * (the `~/.flair/admin-pass` file and the Ed25519 agent-key floor
33
+ * under `~/.flair/keys`). This tier is applied DOWNSTREAM by
34
+ * `authedRequest` (src/lib/auth-resolve.ts, tiers 4-5), not by a
35
+ * name lookup here — a forgotten `--agent` must never silently
36
+ * resolve to some other configured identity. `resolveSigningIdentity`
37
+ * models this tier via its `configProfileAgentId` parameter so the
38
+ * full documented precedence is expressible and unit-testable; the
39
+ * CLI leaves it unset and delegates the tier to `authedRequest`.
40
+ * 4. NONE — nothing resolved by flag/env; the caller decides whether that is
41
+ * fatal (agent-scoped commands demand an explicit identity) or
42
+ * whether to let `authedRequest` apply the tier-3 ambient credential.
43
+ *
44
+ * The signer is resolved ONCE, at the command boundary, and threaded down to
45
+ * `api()`/`authedRequest` as an authoritative value — `api()` no longer
46
+ * re-derives it from the environment behind the caller's back.
47
+ */
48
+ /**
49
+ * The canonical resolver. Pure: every input is a parameter, so precedence is
50
+ * testable in isolation with no filesystem or process state.
51
+ *
52
+ * `configProfileAgentId` is the tier-3 value (the machine's wired agent id);
53
+ * pass `undefined`/`null` when there is no config profile, or when the caller
54
+ * deliberately does not consult one. `env` is injectable for tests; it
55
+ * defaults to `process.env`.
56
+ */
57
+ export function resolveSigningIdentity(opts, configProfileAgentId, env = process.env) {
58
+ if (opts.agent)
59
+ return { agentId: opts.agent, source: "flag" };
60
+ const envId = env.FLAIR_AGENT_ID;
61
+ if (envId)
62
+ return { agentId: envId, source: "env" };
63
+ if (configProfileAgentId)
64
+ return { agentId: configProfileAgentId, source: "config" };
65
+ return { agentId: null, source: "none" };
66
+ }
67
+ /** Human label for a source, used in the debug line and error text. */
68
+ export function describeSigningIdentitySource(source) {
69
+ switch (source) {
70
+ case "flag": return "--agent flag";
71
+ case "env": return "FLAIR_AGENT_ID env";
72
+ case "config": return "config profile";
73
+ case "none": return "no --agent flag, FLAIR_AGENT_ID env, or config-profile agent";
74
+ }
75
+ }
76
+ /**
77
+ * The one-line diagnostic. Names the resolved agentId AND which tier won, so
78
+ * an operator can see — without guessing — who the CLI is about to sign as.
79
+ * Pure string builder; the gate + write live in `emitSigningIdentityDebug`.
80
+ */
81
+ export function formatSigningIdentityDebug(resolved, command) {
82
+ const where = command ? ` for '${command}'` : "";
83
+ if (resolved.agentId === null) {
84
+ return `[flair] signing identity${where}: <none> — ${describeSigningIdentitySource("none")} resolved`;
85
+ }
86
+ return `[flair] signing identity${where}: ${resolved.agentId} (source: ${describeSigningIdentitySource(resolved.source)})`;
87
+ }
88
+ /**
89
+ * Emit the debug line to stderr, gated behind `FLAIR_DEBUG` (any non-empty
90
+ * value). stderr, never stdout, so `--json` output stays clean and pipeable.
91
+ * `env` and `write` are injectable for tests.
92
+ */
93
+ export function emitSigningIdentityDebug(resolved, command, env = process.env, write = (s) => { process.stderr.write(s); }) {
94
+ if (!env.FLAIR_DEBUG)
95
+ return;
96
+ write(formatSigningIdentityDebug(resolved, command) + "\n");
97
+ }
@@ -23,6 +23,15 @@ import { recordCitations } from "./usage-recording.js";
23
23
  function wantsTrust(target, opts) {
24
24
  if (opts?.includeTrust === true)
25
25
  return true;
26
+ // flair#1181 — the in-process STATIC by-id read (resources/mcp-tools.ts
27
+ // memory_get) folds includeTrust into the RequestTarget as a plain property,
28
+ // because Harper's static `Cls.get(target, context)` has no opts slot (arg 2
29
+ // is the context, not opts). This is the in-process analog of the two shapes
30
+ // below; a real RequestTarget from the HTTP path never carries a plain
31
+ // `includeTrust` property (it lives in the query string, read via `.get`),
32
+ // so this is purely additive and does not affect the HTTP path.
33
+ if (target?.includeTrust === true)
34
+ return true;
26
35
  const raw = target?.get?.("includeTrust") ??
27
36
  target?.searchParams?.get?.("includeTrust") ??
28
37
  undefined;
@@ -68,7 +68,13 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
68
68
  * row's `entities`.
69
69
  *
70
70
  * Response:
71
- * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
71
+ * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
72
+ * agentId, scope, soul, memories, predicted[, currentTaskHint] }
73
+ * The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
74
+ * `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
75
+ * (the caller's OWN records as structured containers), and `currentTaskHint`
76
+ * (present only when currentTask is absent/blank) — are ALWAYS emitted so a
77
+ * client can tell an empty instance from one that doesn't support them.
72
78
  */
73
79
  // Collision surfacing (flair#681) tunables.
74
80
  const COLLISION_WINDOW_DAYS = 7;
@@ -187,6 +193,27 @@ export class BootstrapMemories extends Resource {
187
193
  let memoriesIncluded = 0;
188
194
  let memoriesAvailable = 0;
189
195
  let memoriesTruncated = 0;
196
+ // flair#1182 (part 1) — self-describing bootstrap. These structured
197
+ // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
198
+ // the caller has nothing), so a client can tell an *empty* instance from
199
+ // one that doesn't support these keys at all — and can read the caller's
200
+ // own soul/memories as structured data instead of parsing the `context`
201
+ // markdown string. Scoped to the CALLER'S OWN records only
202
+ // (permanent/recent/relevant/predicted are all agentId==self reads);
203
+ // teammate findings stay in `context`/`sections.teammate` and are never
204
+ // duplicated here, so these containers carry no other agent's data.
205
+ const soulMap = {};
206
+ const includedOwnMemories = [];
207
+ const includedPredicted = [];
208
+ const leanMemory = (m, section) => ({
209
+ id: m.id,
210
+ content: m.content,
211
+ durability: m.durability ?? null,
212
+ createdAt: m.createdAt ?? null,
213
+ agentId: m.agentId ?? agentId,
214
+ subject: m.subject ?? null,
215
+ section,
216
+ });
190
217
  // --- 1. Soul records (budgeted — prioritized by key importance) ---
191
218
  // Soul is who you are, but we still need to respect token budgets.
192
219
  // Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
@@ -210,6 +237,9 @@ export class BootstrapMemories extends Resource {
210
237
  skillAssignments.push(record);
211
238
  continue;
212
239
  }
240
+ // flair#1182 — the raw soul container (key→value), independent of the
241
+ // token-budgeted/priority-truncated `sections.soul` lines built below.
242
+ soulMap[record.key] = record.value;
213
243
  const line = `**${record.key}:** ${record.value}`;
214
244
  const tokens = estimateTokens(line);
215
245
  const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
@@ -424,6 +454,7 @@ export class BootstrapMemories extends Resource {
424
454
  const cost = estimateTokens(line);
425
455
  if (cost <= tokenBudget) {
426
456
  sections.permanent.push(line);
457
+ includedOwnMemories.push(leanMemory(m, "permanent"));
427
458
  if (includeTrust)
428
459
  includedTrustMemories.push(m);
429
460
  tokenBudget -= cost;
@@ -498,6 +529,7 @@ export class BootstrapMemories extends Resource {
498
529
  continue;
499
530
  }
500
531
  sections.recent.push(line);
532
+ includedOwnMemories.push(leanMemory(m, "recent"));
501
533
  if (includeTrust)
502
534
  includedTrustMemories.push(m);
503
535
  recentSpent += cost;
@@ -540,6 +572,7 @@ export class BootstrapMemories extends Resource {
540
572
  continue;
541
573
  }
542
574
  sections.predicted.push(line);
575
+ includedPredicted.push(leanMemory(m, "predicted"));
543
576
  if (includeTrust)
544
577
  includedTrustMemories.push(m);
545
578
  predictedSpent += cost;
@@ -716,6 +749,9 @@ export class BootstrapMemories extends Resource {
716
749
  }
717
750
  else {
718
751
  sections.relevant.push(line);
752
+ // flair#1182 — own task-relevant records join the `memories`
753
+ // container; teammate (`_source`) records stay in `context` only.
754
+ includedOwnMemories.push(leanMemory(m, "relevant"));
719
755
  }
720
756
  if (includeTrust)
721
757
  includedTrustMemories.push(m);
@@ -944,8 +980,38 @@ export class BootstrapMemories extends Resource {
944
980
  // decision reads only the confidence number — never a principal — against
945
981
  // the single GLOBAL threshold.
946
982
  const abstention = abstain ? evaluateAbstention(taskBestSimilarity) : undefined;
983
+ // flair#1182 (part 1) — resolved identity + read-scope descriptor. Reveals
984
+ // ONLY the caller's own resolved identity/scope (who the server decided the
985
+ // caller is, and the read model applied to them) — never another agent's
986
+ // data. Would have made the #1181 read-gate bug a one-call diagnosis.
987
+ const scopeInfo = {
988
+ agentId,
989
+ isAdmin: callerIsAdmin,
990
+ // The read model resolveReadScope(agentId) enforces for this caller: the
991
+ // caller's own records (any visibility) plus every other in-org agent's
992
+ // non-private record. Keep in sync with resources/memory-read-scope.ts.
993
+ reads: "own-and-org-non-private",
994
+ };
995
+ // flair#1182 (part 1) — currentTask is what turns on task-relevant
996
+ // retrieval, teammate findings and collision surfacing. When it's absent or
997
+ // blank, say so in the response so a caller learns the knob exists (present
998
+ // ONLY when absent/blank — a provided task needs no hint).
999
+ const taskProvided = typeof currentTask === "string" && currentTask.trim().length > 0;
1000
+ const currentTaskHint = taskProvided
1001
+ ? undefined
1002
+ : "No currentTask was provided. Pass currentTask (a short description of what you're working on) to enable task-relevant memory retrieval, teammate findings, and collision surfacing.";
947
1003
  return {
948
1004
  context,
1005
+ // flair#1182 (part 1) — always-present self-describing keys: who the
1006
+ // server resolved the caller as, the read model applied, and the caller's
1007
+ // own soul/memories/predicted as structured containers (empty `{}`/`[]`,
1008
+ // never absent, so "empty" is distinguishable from "unsupported").
1009
+ agentId,
1010
+ scope: scopeInfo,
1011
+ soul: soulMap,
1012
+ memories: includedOwnMemories,
1013
+ predicted: includedPredicted,
1014
+ ...(currentTaskHint ? { currentTaskHint } : {}),
949
1015
  ...(trust ? { trust } : {}),
950
1016
  ...(abstention ? { abstention } : {}),
951
1017
  sections: {
@@ -115,6 +115,30 @@ async function unwrap(value) {
115
115
  }
116
116
  return value;
117
117
  }
118
+ /**
119
+ * flair#1188 — remove the raw `embedding` vector from a record before it is
120
+ * returned over the MCP surface. A stored memory carries a 768-float
121
+ * `embedding` (the HNSW vector); inlined into a tool result that is thousands
122
+ * of noise tokens per record on chat connectors that have a fixed context
123
+ * budget, and the caller can never do anything useful with it. Returns a
124
+ * shallow copy WITHOUT `embedding` (never mutates the source record), and
125
+ * passes through anything that is not a plain record — null, primitives,
126
+ * arrays, and the `{ error, status }` shapes `unwrap` produces — untouched.
127
+ *
128
+ * `memory_search` already projects with an explicit select that omits
129
+ * `embedding` (resources/semantic-retrieval-core.ts's DEFAULT_SELECT), and
130
+ * `bootstrap` uses the same select-without-embedding pushdown, so this is only
131
+ * needed on the FULL-record read/write paths (memory_get, and the write
132
+ * responses that echo the stored row).
133
+ */
134
+ function stripEmbedding(value) {
135
+ if (!value || typeof value !== "object" || Array.isArray(value))
136
+ return value;
137
+ if (!("embedding" in value))
138
+ return value;
139
+ const { embedding, ...rest } = value;
140
+ return rest;
141
+ }
118
142
  // ── Tool implementations (thin wrappers over existing handlers) ──────────────
119
143
  //
120
144
  // Each takes the resolved agent + the parsed tool arguments and returns a plain
@@ -190,7 +214,10 @@ async function memoryStore(agent, args) {
190
214
  }
191
215
  body.visibility = args.visibility;
192
216
  }
193
- return unwrap(await h.post(body));
217
+ // flair#1188 — memory_store's response goes through the same buildWriteResponse
218
+ // echo as memory_update; strip the server-regenerated embedding so no write
219
+ // tool ever inlines the vector. No-op when the response carries none.
220
+ return stripEmbedding(await unwrap(await h.post(body)));
194
221
  }
195
222
  /**
196
223
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
@@ -213,11 +240,15 @@ async function memoryStore(agent, args) {
213
240
  */
214
241
  async function memoryUpdate(agent, args) {
215
242
  const Cls = await handler("Memory");
216
- const h = new Cls(undefined, delegationContext(agent));
217
243
  const id = args?.id;
218
244
  const content = args?.content;
219
245
  const preserveHistory = args?.preserveHistory === true;
220
- const existing = await h.get(id);
246
+ // flair#1181 — the existing-record fetch is a by-id READ and must use the
247
+ // STATIC `Cls.get(id, context)` form (see memoryGet). The instance
248
+ // `new Cls(undefined, ctx).get(id)` returned `undefined` for the caller's own
249
+ // record (getProperty on an unloaded instance), so memory_update 404'd
250
+ // ("memory not found") on the connector path before it ever reached a write.
251
+ const existing = await Cls.get(id, delegationContext(agent));
221
252
  if (!existing) {
222
253
  return { error: "memory not found", status: 404 };
223
254
  }
@@ -236,15 +267,31 @@ async function memoryUpdate(agent, args) {
236
267
  delete record.validFrom;
237
268
  delete record.validTo;
238
269
  delete record.archivedAt;
270
+ // flair#1189 — retrievalCount and lastRetrieved are RECORD-scoped, not
271
+ // lineage-scoped: a brand-new successor record has no retrieval history of
272
+ // its OWN, so it must start with none. Inheriting them from the superseded
273
+ // record via the `...existing` spread produced a successor whose
274
+ // lastRetrieved PREDATED its own createdAt ("retrieved 8h before it
275
+ // existed"), silently corrupting any recency/usage-based ranking that reads
276
+ // these fields. Reset both here, at succession construction — NOT server-
277
+ // side, because `supersedes` is a PERMANENT property of every successor and
278
+ // legitimate later retrievalCount bumps route through put() on a record that
279
+ // still carries it. Usage/citation-ledger counters (usageCount, the #1147
280
+ // citation ledger) are a SEPARATE, arguably lineage-scoped question and are
281
+ // deliberately left untouched here (#1147's usage loop is currently inert).
282
+ record.retrievalCount = 0;
283
+ delete record.lastRetrieved;
239
284
  // flair#718 authorship-provenance — see memoryStore's comment: forward
240
285
  // the resolved OAuth client_id (never forgeable via args) so the NEW
241
286
  // version's provenance records which client authored this update.
242
287
  if (agent.clientId)
243
288
  record.claimedClient = agent.clientId;
244
- // A create needs a COLLECTION-bound instance (see resources/in-process.ts);
245
- // `h` above is the by-id handle the get()/put() branches use.
289
+ // A create needs a COLLECTION-bound instance (see resources/in-process.ts).
246
290
  const coll = await collectionResource(Cls, delegationContext(agent));
247
- return unwrap(await coll.post(record));
291
+ // flair#1188 — the write response echoes the stored row (Memory.post
292
+ // regenerates the embedding server-side), so strip the vector before it
293
+ // returns over the MCP surface. No-op when the response carries none.
294
+ return stripEmbedding(await unwrap(await coll.post(record)));
248
295
  }
249
296
  const merged = { ...existing, content, updatedAt: new Date().toISOString() };
250
297
  delete merged.embedding;
@@ -252,21 +299,59 @@ async function memoryUpdate(agent, args) {
252
299
  // flair#718 authorship-provenance — see memoryStore's comment above.
253
300
  if (agent.clientId)
254
301
  merged.claimedClient = agent.clientId;
255
- return unwrap(await h.put(merged));
302
+ // flair#1181 — the default merge write must ALSO use the STATIC transactional
303
+ // path, `Cls.put(merged, context)`, NOT an instance `new Cls(undefined, ctx).put(merged)`.
304
+ // An unloaded instance has no primary key, so its put()/save() throws
305
+ // "Invalid primary key type: undefined" (proven by the memory_update
306
+ // round-trip integration test). The pre-#1181 code never hit this because the
307
+ // broken by-id read above 404'd first — migrating the read to static exposed
308
+ // the same unloaded-instance defect on the write. The static form loads the
309
+ // row by `merged.id` and threads the context, then dispatches through
310
+ // Memory.put()'s own ownership gate — no scope change, same as the read.
311
+ // flair#1188 — strip the embedding from the echoed write response (Memory.put
312
+ // regenerates the vector server-side); no-op when the response carries none.
313
+ return stripEmbedding(await unwrap(await Cls.put(merged, delegationContext(agent))));
256
314
  }
257
315
  async function memoryGet(agent, args) {
258
316
  const Cls = await handler("Memory");
259
- const h = new Cls(undefined, delegationContext(agent));
260
- // flair#744 slice 1 opt-in inline trust block on the returned record.
261
- // Pass the opts arg ONLY when requested so a plain get() call is unchanged.
262
- return unwrap(args?.includeTrust === true
263
- ? await h.get(args?.id, { includeTrust: true })
264
- : await h.get(args?.id));
317
+ // flair#1181 by-id reads MUST use the STATIC `Cls.get(id, context)` form,
318
+ // never an instance `new Cls(undefined, ctx).get(id)`. Harper routes an
319
+ // instance `.get(<string>)` on an UNLOADED record (tables leave
320
+ // `loadAsInstance` at its `undefined` default) to `getProperty()` a field
321
+ // accessor that returns `undefined` so the read never loads the row and
322
+ // `makeByIdReadGate`'s `!record` branch 404s the caller's OWN record (one
323
+ // call after a successful store). The static form is the same transactional
324
+ // path the Ed25519 REST route takes: it loads the row, hands the override a
325
+ // `RequestTarget` (never a bare string), and still dispatches through
326
+ // Memory.get() → makeByIdReadGate → resolveReadScope, so the scope model is
327
+ // unchanged (own + org-non-private only). See resources/in-process.ts:223.
328
+ //
329
+ // flair#744 slice 1 — opt-in inline trust block. The instance call passed
330
+ // `includeTrust` as a 2nd positional opts arg to get(); the static form has
331
+ // no opts slot (arg 2 is the context), so fold it into the RequestTarget as
332
+ // a plain `{ id, includeTrust }` property — Memory.get()'s wantsTrust() reads
333
+ // it there (the in-process shape alongside the HTTP query-param shape).
334
+ const target = args?.includeTrust === true ? { id: args?.id, includeTrust: true } : args?.id;
335
+ const result = await unwrap(await Cls.get(target, delegationContext(agent)));
336
+ // flair#1188 — a by-id get loads the FULL record, including the 768-float
337
+ // `embedding` vector (search/bootstrap project it out; a raw get does not).
338
+ // Strip it by default so a chat connector isn't flooded with thousands of
339
+ // useless tokens per record; return it only when the caller explicitly opts
340
+ // in via includeEmbedding.
341
+ return args?.includeEmbedding === true ? result : stripEmbedding(result);
265
342
  }
266
343
  async function memoryDelete(agent, args) {
267
344
  const Cls = await handler("Memory");
268
- const h = new Cls(undefined, delegationContext(agent));
269
- return unwrap(await h.delete(args?.id));
345
+ // flair#1181 STATIC `Cls.delete(id, context)`, not an instance
346
+ // `new Cls(undefined, ctx).delete(id)`. Memory.delete()'s override loads the
347
+ // row via `super.get(id)` to enforce the permanent-memory admin guard; on an
348
+ // unloaded instance that `super.get(<string>)` hit the same `getProperty()`
349
+ // dead end (`undefined`), so the guard's `record.durability === "permanent"`
350
+ // check was SILENTLY SKIPPED and the delete fell through to an unguarded
351
+ // `super.delete(id)`. The static form loads the row first (RequestTarget,
352
+ // not a bare string), so the override sees the real record and the
353
+ // ownership/durability guard runs as intended.
354
+ return unwrap(await Cls.delete(args?.id, delegationContext(agent)));
270
355
  }
271
356
  async function bootstrap(agent, args) {
272
357
  const Cls = await handler("BootstrapMemories");
@@ -315,8 +400,14 @@ async function soulSet(agent, args) {
315
400
  }
316
401
  async function soulGet(agent, args) {
317
402
  const Cls = await handler("Soul");
318
- const h = new Cls(undefined, delegationContext(agent));
319
- return unwrap(await h.get(`${agent.agentId}:${args?.key}`));
403
+ // flair#1181 STATIC by-id read (see memoryGet). Soul has no get() override
404
+ // and no read-scope gate; its ids are `${agentId}:${key}`, built here from
405
+ // the RESOLVED agent, so a caller can only ever address its OWN soul — the
406
+ // static migration does not change that. The instance `h.get(<string>)`
407
+ // returned `undefined` (getProperty on an unloaded record), which is why
408
+ // soul_get came back empty on the connector path even though the entries
409
+ // exist and load fine via the Ed25519 static route.
410
+ return unwrap(await Cls.get(`${agent.agentId}:${args?.key}`, delegationContext(agent)));
320
411
  }
321
412
  async function workspaceSet(agent, args) {
322
413
  const Cls = await handler("WorkspaceState");
@@ -475,13 +566,14 @@ export const TOOLS = {
475
566
  memory_get: {
476
567
  def: {
477
568
  name: "memory_get",
478
- description: "Retrieve a specific memory by ID.",
569
+ description: "Retrieve a specific memory by ID. The record's raw embedding vector is omitted by default (it is large and not useful to a caller); pass includeEmbedding=true to include it.",
479
570
  annotations: { readOnlyHint: true },
480
571
  inputSchema: {
481
572
  type: "object",
482
573
  properties: {
483
574
  id: { type: "string", description: "Memory ID" },
484
575
  includeTrust: { type: "boolean", description: "Attach a trust-evidence block (provenance, author, usage, freshness, supersession) to the record. Default false." },
576
+ includeEmbedding: { type: "boolean", description: "Include the raw embedding vector (hundreds of floats) in the returned record. Omitted by default because it is large and rarely useful to a caller. Default false." },
485
577
  },
486
578
  required: ["id"],
487
579
  },
@@ -184,6 +184,10 @@ export function makeReadScope(mode, ownerField = "agentId") {
184
184
  * where it was.
185
185
  */
186
186
  export function makeByIdReadGate(readScope) {
187
+ // flair#1181 — the owner field this gate's read-scope keys on, used ONLY to
188
+ // annotate the diagnostic logs below. Tagged onto the resolver by
189
+ // makeReadScope(); "agentId" for every table wired through this kit today.
190
+ const ownerField = readScope?.ownerField ?? "agentId";
187
191
  return async function byIdReadGate(target, superGet) {
188
192
  // Collection / query reads arrive as a RequestTarget with
189
193
  // `isCollection === true`, and are governed by search() (same owner
@@ -191,23 +195,48 @@ export function makeByIdReadGate(readScope) {
191
195
  if (!target || (typeof target === "object" && target.isCollection)) {
192
196
  return this.search(target);
193
197
  }
198
+ // flair#1181 — a single debug breadcrumb per DENY/ABSENT outcome so the
199
+ // next 404-on-your-own-record is a one-log diagnosis instead of a re-derive
200
+ // from Anthropic request ids. The three outcomes below are otherwise
201
+ // indistinguishable to the caller BY DESIGN — the client ALWAYS receives
202
+ // NOT_FOUND (404-never-403, so a denied caller can't enumerate other
203
+ // agents' ids). This logging is server-side only and changes no response.
204
+ const table = this?.constructor?.name;
205
+ const targetId = typeof target === "string" ? target : target?.id;
194
206
  const ctx = this.getContext?.();
195
207
  const auth = await resolveAgentAuth(ctx);
196
208
  // Anonymous by-id read is already blocked at the allowRead() gate (403);
197
209
  // this is defense-in-depth if get() is ever reached directly.
198
- if (auth.kind === "anonymous")
210
+ if (auth.kind === "anonymous") {
211
+ console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
212
+ table, targetId, resolvedAgentId: undefined, recordLoaded: false, recordOwner: undefined, branch: "anonymous",
213
+ });
199
214
  return NOT_FOUND();
215
+ }
200
216
  // Trusted internal call or admin agent — unfiltered, unchanged behavior.
201
217
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
202
218
  return superGet(target);
203
219
  }
204
220
  // Non-admin agent: scoped per the table's own read-scope model.
205
221
  const record = await superGet(target);
206
- if (!record)
222
+ if (!record) {
223
+ // Row did not load: either it genuinely does not exist, OR — the #1181
224
+ // failure — a by-id read reached here on an unloaded instance and
225
+ // getProperty()'d to undefined. `recordLoaded: false` is the tell.
226
+ console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
227
+ table, targetId, resolvedAgentId: auth.agentId, recordLoaded: false, recordOwner: undefined, branch: "absent-or-failed-load",
228
+ });
207
229
  return NOT_FOUND();
230
+ }
208
231
  const scope = await readScope(auth.agentId);
209
- if (!scope.isAllowed(record))
232
+ if (!scope.isAllowed(record)) {
233
+ // Row loaded and is owned by someone this caller may not read — a genuine
234
+ // ownership denial, distinct from the absent/failed-load branch above.
235
+ console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
236
+ table, targetId, resolvedAgentId: auth.agentId, recordLoaded: true, recordOwner: record?.[ownerField], branch: "denied",
237
+ });
210
238
  return NOT_FOUND();
239
+ }
211
240
  return record;
212
241
  };
213
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.3",
3
+ "version": "0.44.5",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",