@tpsdev-ai/flair 0.51.1 → 0.52.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 +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +13 -8
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
package/dist/cli.js
CHANGED
|
@@ -20,13 +20,13 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
|
|
|
20
20
|
import { probeInstance } from "./probe.js";
|
|
21
21
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
22
22
|
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
23
|
-
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi,
|
|
23
|
+
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
24
24
|
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
25
25
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
26
26
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
27
|
-
import { readClientMcpBlock, effectiveFlairUrl,
|
|
27
|
+
import { readClientMcpBlock, effectiveFlairUrl, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, checkSessionStartHookPinSkew, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
28
28
|
import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
|
|
29
|
-
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
|
|
29
|
+
import { installHook, uninstallHook, repinSessionStartHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
|
|
30
30
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
31
31
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
32
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
@@ -41,6 +41,8 @@ import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartT
|
|
|
41
41
|
// functions — this pulls in nothing but node builtins.
|
|
42
42
|
import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
|
|
43
43
|
import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
|
|
44
|
+
import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage, } from "./lib/upgrade-exec-path.js";
|
|
45
|
+
import { applyPlainTreeUpgrade, decidePlainTreeRollback, discardPlainTreePrevious, findSystemdUnitsForTree, formatPlainTreeBanner, formatPlainTreePlan, formatPlainTreeScopeFooter, planPlainTreeUpgrade, resolvePlainTreeListingTarget, resolvePlainTreeTarget, restartSystemdUnits, restorePlainTreePrevious, } from "./lib/upgrade-plain-tree.js";
|
|
44
46
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
45
47
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
46
48
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -75,6 +77,16 @@ function signBody(body, secretKey) {
|
|
|
75
77
|
// it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
|
|
76
78
|
// no longer informational-only. Credential.principalId is an unrelated
|
|
77
79
|
// owner field — do not grep that path when changing this one.
|
|
80
|
+
//
|
|
81
|
+
// S2 COMMENT-PIN (Kern P2-5, flair#1521): Message is principal-owning by `from`
|
|
82
|
+
// (PRINCIPAL_OWNER_FIELD in federation-classify.ts) but its rows carry NO
|
|
83
|
+
// `provenance` stamp, so principalIdFromRow returns undefined for them → a v:2
|
|
84
|
+
// Message push would omit `principalId` → the receiver's v≥2
|
|
85
|
+
// checkPrincipalEntitlement skips EVERY Message as `principal_mismatch` (a
|
|
86
|
+
// 100%-skip sync, not a migration). S1 does not push Message (receive-only —
|
|
87
|
+
// the spoke push list below is a separate hardcoded set), so this is inert
|
|
88
|
+
// today; the S2 pusher MUST stamp `principalId = row.from` for Message here
|
|
89
|
+
// (a per-table owner-aware derivation, not `provenance.verified.agentId`).
|
|
78
90
|
function principalIdFromRow(row) {
|
|
79
91
|
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
80
92
|
return undefined;
|
|
@@ -687,10 +699,14 @@ function resolveSigningIdentityFor(opts, command) {
|
|
|
687
699
|
emitSigningIdentityDebug(resolved, command);
|
|
688
700
|
return resolved;
|
|
689
701
|
}
|
|
690
|
-
// Same seam, returning
|
|
691
|
-
//
|
|
702
|
+
// Same seam, returning the full ResolvedSigningIdentity (agentId + source) so
|
|
703
|
+
// callers can thread the source down to api()/authedRequest. The source is what
|
|
704
|
+
// lets authedRequest distinguish a flag-pinned agent (--agent X, flair#1500)
|
|
705
|
+
// from an env-pinned one (FLAIR_AGENT_ID) — the flag must sign as itself BEFORE
|
|
706
|
+
// env admin, while the env keeps its legacy Basic behavior. Still emits the
|
|
707
|
+
// debug line via resolveSigningIdentityFor.
|
|
692
708
|
function resolveSigningAgentId(opts, command) {
|
|
693
|
-
return resolveSigningIdentityFor(opts, command)
|
|
709
|
+
return resolveSigningIdentityFor(opts, command);
|
|
694
710
|
}
|
|
695
711
|
// ── Shared credential/identity flag surface (flair#1106) ─────────────────────
|
|
696
712
|
// Sibling commands (memory add, backup, federation sync) used to drift on
|
|
@@ -1484,6 +1500,7 @@ async function api(method, path, body, options) {
|
|
|
1484
1500
|
keysDir: options?.keysDir,
|
|
1485
1501
|
explicitAdminPass: options?.explicitAdminPass,
|
|
1486
1502
|
adminUser: options?.adminUser,
|
|
1503
|
+
agentIdSource: options?.agentIdSource,
|
|
1487
1504
|
});
|
|
1488
1505
|
}
|
|
1489
1506
|
/**
|
|
@@ -2413,10 +2430,30 @@ const FLAIR_AGENT_PERMISSION = {
|
|
|
2413
2430
|
Memory: grant(true, true, true, true),
|
|
2414
2431
|
MemoryCandidate: grant(true, true, true, true),
|
|
2415
2432
|
MemoryGrant: grant(true, true, true, true),
|
|
2433
|
+
// Asset (images-in-Flair slice 1). Harper authorizes BEFORE Asset.post
|
|
2434
|
+
// runs, so a de-elevated flair_agent needs the table grant or signed
|
|
2435
|
+
// POST /Asset 403s as AccessViolation (Kern P0). CRUD envelope;
|
|
2436
|
+
// owner-only + write-time size/MIME gates live in resources/Asset.ts.
|
|
2437
|
+
Asset: grant(true, true, true, true),
|
|
2416
2438
|
Soul: grant(true, true, true, false),
|
|
2417
2439
|
OrgEvent: grant(true, true, true, true),
|
|
2418
2440
|
WorkspaceState: grant(true, true, true, true),
|
|
2419
2441
|
Relationship: grant(true, true, true, true),
|
|
2442
|
+
// Flair Relay S1 (flair#1521). Harper authorizes BEFORE the resource
|
|
2443
|
+
// methods run, so a de-elevated flair_agent needs the table grant OR it
|
|
2444
|
+
// 403s on POST /Message before relaySend is reached (Kern P0-2). read =
|
|
2445
|
+
// the party-scoped collection (Message.search); insert = send via post().
|
|
2446
|
+
// update = FALSE (least privilege, Kern P0 blocker): the ack's write goes
|
|
2447
|
+
// through the IN-PROCESS static accessor (relayConsume → deps.messages.put),
|
|
2448
|
+
// which bypasses role gates entirely — the same raw-put seam Federation.ts
|
|
2449
|
+
// relies on — so update:true is NOT needed for any legitimate path. Leaving
|
|
2450
|
+
// it granted let PATCH /Message/<id> reach Table's update verb, whose
|
|
2451
|
+
// authorize step consults update:true and PASSES for any de-elevated agent,
|
|
2452
|
+
// bypassing Message.put()'s FORBIDDEN guard AND relayConsume's recipient-only
|
|
2453
|
+
// check (Message has no patch() at the platform level → TableResource.patch
|
|
2454
|
+
// runs update()+save() directly). delete = false: direct deletes are
|
|
2455
|
+
// admin/internal only. Message.patch() also guards the verb in-resource.
|
|
2456
|
+
Message: grant(true, true, false, false),
|
|
2420
2457
|
Integration: grant(true, true, true, true),
|
|
2421
2458
|
Credential: grant(true, true, true, true),
|
|
2422
2459
|
Presence: grant(true, true, true, false),
|
|
@@ -2430,6 +2467,9 @@ const FLAIR_AGENT_PERMISSION = {
|
|
|
2430
2467
|
// (create → count → delete → count again → repeat), defeating the cap
|
|
2431
2468
|
// entirely. See resources/MemoryUsage.ts's module doc.
|
|
2432
2469
|
MemoryUsage: grant(true, true, false, false),
|
|
2470
|
+
// MemoryHitStat (flair#1528): internal search-hit ledger. No agent REST
|
|
2471
|
+
// surface (@table without @export). Counts overlay onto Memory reads.
|
|
2472
|
+
MemoryHitStat: grant(false, false, false, false),
|
|
2433
2473
|
// Agent: read for discovery, update own card; creation/removal is admin.
|
|
2434
2474
|
Agent: grant(true, false, true, false),
|
|
2435
2475
|
// Read-only reference data.
|
|
@@ -3672,7 +3712,7 @@ program
|
|
|
3672
3712
|
console.log("");
|
|
3673
3713
|
for (const [key, value] of soulEntries) {
|
|
3674
3714
|
try {
|
|
3675
|
-
await
|
|
3715
|
+
await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
|
|
3676
3716
|
console.log(` ✓ soul:${key} set`);
|
|
3677
3717
|
}
|
|
3678
3718
|
catch (err) {
|
|
@@ -4474,8 +4514,9 @@ agent
|
|
|
4474
4514
|
/** Best-effort seed-validity check for a `.key` file: does it parse via any
|
|
4475
4515
|
* of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
|
|
4476
4516
|
* — the same loader `flair mcp token` uses) accepts? Never throws — used
|
|
4477
|
-
* only to decide "
|
|
4478
|
-
* actually sign anything.
|
|
4517
|
+
* only to decide "unidentified" vs. "worth a registration check", not to
|
|
4518
|
+
* actually sign anything. An unparseable file is not junk: the keys dir is
|
|
4519
|
+
* also where FileKeyStore writes AES-256-GCM blobs (flair#1026). */
|
|
4479
4520
|
function isValidPrivateKeySeedFile(keyPath) {
|
|
4480
4521
|
try {
|
|
4481
4522
|
loadEd25519PrivateKeyFromFile(keyPath);
|
|
@@ -4490,10 +4531,11 @@ function isValidPrivateKeySeedFile(keyPath) {
|
|
|
4490
4531
|
* never writes or moves anything; see applyKeyPrune below for the actual
|
|
4491
4532
|
* move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
|
|
4492
4533
|
* and files not ending in `.key` are "ignored" without any network call.
|
|
4493
|
-
* `.key` files with an unparseable seed are "
|
|
4494
|
-
* either —
|
|
4495
|
-
* `GET /Agent/:id` against `baseUrl`
|
|
4496
|
-
* same check doctor's registration
|
|
4534
|
+
* `.key` files with an unparseable seed are "unidentified" without a network
|
|
4535
|
+
* call either — reported, never pruned (flair#1026). Only a `.key` file that
|
|
4536
|
+
* DOES parse triggers a signed `GET /Agent/:id` against `baseUrl`
|
|
4537
|
+
* (checkAgentRegistered above, the exact same check doctor's registration
|
|
4538
|
+
* gate uses).
|
|
4497
4539
|
*
|
|
4498
4540
|
* If that check EVER reports "unreachable" — the instance couldn't be
|
|
4499
4541
|
* confirmed up for that key — the WHOLE run aborts immediately
|
|
@@ -4544,7 +4586,7 @@ export async function classifyKeysDir(keysDir, baseUrl) {
|
|
|
4544
4586
|
// flair#1023 added "key-unreadable". It cannot occur here — this key's
|
|
4545
4587
|
// seed already parsed via isValidPrivateKeySeedFile above — but is
|
|
4546
4588
|
// handled explicitly rather than folded into the else: a key that will
|
|
4547
|
-
// not load
|
|
4589
|
+
// not load is "unidentified", not prunable "invalid" (flair#1026).
|
|
4548
4590
|
const decision = reg.state === "key-unreadable"
|
|
4549
4591
|
? classifyKeyFile(c.agentId, false, null, baseUrl)
|
|
4550
4592
|
: classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
|
|
@@ -4604,10 +4646,11 @@ keys
|
|
|
4604
4646
|
}
|
|
4605
4647
|
const stale = result.entries.filter((e) => e.class === "stale");
|
|
4606
4648
|
const invalid = result.entries.filter((e) => e.class === "invalid");
|
|
4649
|
+
const unidentified = result.entries.filter((e) => e.class === "unidentified");
|
|
4607
4650
|
const kept = result.entries.filter((e) => e.class === "keep");
|
|
4608
4651
|
const ignored = result.entries.filter((e) => e.class === "ignored");
|
|
4609
4652
|
const prunable = [...stale, ...invalid];
|
|
4610
|
-
if (stale.length + invalid.length + kept.length === 0) {
|
|
4653
|
+
if (stale.length + invalid.length + unidentified.length + kept.length === 0) {
|
|
4611
4654
|
console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
|
|
4612
4655
|
console.log("");
|
|
4613
4656
|
return;
|
|
@@ -4616,12 +4659,15 @@ keys
|
|
|
4616
4659
|
const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
|
|
4617
4660
|
console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
|
|
4618
4661
|
}
|
|
4662
|
+
for (const e of unidentified) {
|
|
4663
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.bold, e.name)} — unidentified: ${e.reason}`);
|
|
4664
|
+
}
|
|
4619
4665
|
for (const e of kept) {
|
|
4620
4666
|
console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
|
|
4621
4667
|
}
|
|
4622
4668
|
if (!apply) {
|
|
4623
4669
|
console.log("");
|
|
4624
|
-
console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${ignored.length} ignored`)}`);
|
|
4670
|
+
console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${unidentified.length} unidentified (left in place), ${ignored.length} ignored`)}`);
|
|
4625
4671
|
if (prunable.length > 0) {
|
|
4626
4672
|
console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
|
|
4627
4673
|
}
|
|
@@ -4633,7 +4679,7 @@ keys
|
|
|
4633
4679
|
for (const m of moved) {
|
|
4634
4680
|
console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
|
|
4635
4681
|
}
|
|
4636
|
-
console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
|
|
4682
|
+
console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${unidentified.length} unidentified (left in place), ${ignored.length} ignored\n`);
|
|
4637
4683
|
});
|
|
4638
4684
|
// ─── flair hook ──────────────────────────────────────────────────────────────
|
|
4639
4685
|
// Ambient memory via harness SessionStart hooks (flair#745, design record
|
|
@@ -7937,6 +7983,9 @@ rem
|
|
|
7937
7983
|
.command("candidates")
|
|
7938
7984
|
.description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
|
|
7939
7985
|
.option("--port <port>", "Harper HTTP port")
|
|
7986
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
7987
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
7988
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7940
7989
|
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
7941
7990
|
.option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
|
|
7942
7991
|
.option("--json", "Output as JSON for scripting")
|
|
@@ -7952,16 +8001,35 @@ rem
|
|
|
7952
8001
|
console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
|
|
7953
8002
|
process.exit(1);
|
|
7954
8003
|
}
|
|
8004
|
+
const opsPort = resolveOpsPort(opts);
|
|
8005
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8006
|
+
if (!adminPass) {
|
|
8007
|
+
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
8008
|
+
process.exit(1);
|
|
8009
|
+
}
|
|
8010
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
7955
8011
|
try {
|
|
7956
|
-
const
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
8012
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
8013
|
+
method: "POST",
|
|
8014
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
8015
|
+
body: JSON.stringify({
|
|
8016
|
+
operation: "search_by_conditions",
|
|
8017
|
+
schema: "flair",
|
|
8018
|
+
table: "MemoryCandidate",
|
|
8019
|
+
operator: "and",
|
|
8020
|
+
conditions: [
|
|
8021
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
8022
|
+
{ search_attribute: "status", search_type: "equals", search_value: status },
|
|
8023
|
+
],
|
|
8024
|
+
get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
|
|
8025
|
+
}),
|
|
7963
8026
|
});
|
|
7964
|
-
|
|
8027
|
+
if (!res.ok) {
|
|
8028
|
+
const text = await res.text().catch(() => "");
|
|
8029
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
8030
|
+
process.exit(1);
|
|
8031
|
+
}
|
|
8032
|
+
const candidates = await res.json();
|
|
7965
8033
|
const mode = render.resolveOutputMode(opts);
|
|
7966
8034
|
if (mode === "json") {
|
|
7967
8035
|
console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
|
|
@@ -8011,209 +8079,8 @@ rem
|
|
|
8011
8079
|
// Pure validators extracted for testability. The action callbacks below thread
|
|
8012
8080
|
// these through process.exit on failure; the helpers themselves are
|
|
8013
8081
|
// side-effect-free.
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
return "--rationale is required (per spec § 5: no rubber-stamp)";
|
|
8017
|
-
}
|
|
8018
|
-
if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
|
|
8019
|
-
return "--to must be 'soul' or 'memory'";
|
|
8020
|
-
}
|
|
8021
|
-
if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
|
|
8022
|
-
return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
|
|
8023
|
-
}
|
|
8024
|
-
return null;
|
|
8025
|
-
}
|
|
8026
|
-
export function validateRejectOpts(opts) {
|
|
8027
|
-
if (!opts.reason || !opts.reason.trim()) {
|
|
8028
|
-
return "--reason is required";
|
|
8029
|
-
}
|
|
8030
|
-
return null;
|
|
8031
|
-
}
|
|
8032
|
-
/**
|
|
8033
|
-
* Decide whether a promote/reject action can proceed against a candidate's
|
|
8034
|
-
* current state, and what message to surface to the operator. Pure function;
|
|
8035
|
-
* action side effects happen in the CLI body after this returns ok.
|
|
8036
|
-
*/
|
|
8037
|
-
export function decideCandidateAction(candidate, action) {
|
|
8038
|
-
if (!candidate)
|
|
8039
|
-
return { ok: false, severity: "error", message: "candidate not found" };
|
|
8040
|
-
const status = candidate.status;
|
|
8041
|
-
if (status === "promoted") {
|
|
8042
|
-
return action === "promote"
|
|
8043
|
-
? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
|
|
8044
|
-
: { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
|
|
8045
|
-
}
|
|
8046
|
-
if (status === "rejected") {
|
|
8047
|
-
return action === "reject"
|
|
8048
|
-
? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
|
|
8049
|
-
: { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
|
|
8050
|
-
}
|
|
8051
|
-
return { ok: true };
|
|
8052
|
-
}
|
|
8053
|
-
// ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
|
|
8054
|
-
// ADK session records are written by adk-flair (memory_service.py) under a
|
|
8055
|
-
// SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
|
|
8056
|
-
// compound scope tag `adk:<app>:<user>`. That tag is the access-control
|
|
8057
|
-
// boundary. A candidate distilled from those records therefore MUST carry the
|
|
8058
|
-
// scope tag when promoted, or the promoted claim lands in the shared agentId
|
|
8059
|
-
// memory retrievable by every other user of the app — a cross-user leak.
|
|
8060
|
-
//
|
|
8061
|
-
// `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
|
|
8062
|
-
// and DROPPED the source tag. We now propagate the source scope tag for
|
|
8063
|
-
// ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
|
|
8064
|
-
// ADK-sourced but its scope tag can't be uniquely+completely determined.
|
|
8065
|
-
//
|
|
8066
|
-
// SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
|
|
8067
|
-
// candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
|
|
8068
|
-
// as before — a transient/deleted source on a non-ADK candidate must NOT block
|
|
8069
|
-
// its promotion.
|
|
8070
|
-
//
|
|
8071
|
-
// SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
|
|
8072
|
-
// is detected here by re-reading the candidate's source memories and inspecting
|
|
8073
|
-
// their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
|
|
8074
|
-
// of whose source memories are unreadable (deleted/transient) yields no `adk:`
|
|
8075
|
-
// evidence and is treated as non-ADK. Closing that corner without regressing
|
|
8076
|
-
// non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
|
|
8077
|
-
// onto the MemoryCandidate row at distillation time (it distills per single
|
|
8078
|
-
// scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
|
|
8079
|
-
// written so that override can be threaded in later without touching callers.
|
|
8080
|
-
export const ADK_SCOPE_TAG_PREFIX = "adk:";
|
|
8081
|
-
/**
|
|
8082
|
-
* Decide the tag set for a promoted Memory given the candidate id and the
|
|
8083
|
-
* result of fetching each of its source memories. Pure — no I/O; the action
|
|
8084
|
-
* callback does the fetching and threads the results here so this is unit-
|
|
8085
|
-
* testable and the fail-closed logic is exercised directly.
|
|
8086
|
-
*
|
|
8087
|
-
* `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
|
|
8088
|
-
* anticipated): the authoritative scope:"tagged" tag the distillation engine
|
|
8089
|
-
* stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
|
|
8090
|
-
* buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
|
|
8091
|
-
* the source re-read entirely — the engine distilled under exactly this one
|
|
8092
|
-
* tag, so it knows the per-user scope tag independent of whether the source
|
|
8093
|
-
* memories are still readable. This closes the residual fail-open the SEAM
|
|
8094
|
-
* note describes: a candidate all of whose sources are unreadable yields no
|
|
8095
|
-
* `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
|
|
8096
|
-
* tagless into the shared agentId namespace (a cross-user leak). Threading it
|
|
8097
|
-
* in as an optional trailing arg keeps every pre-#1205b caller (and every
|
|
8098
|
-
* candidate that never carried a stamp) on the unchanged source-re-read path.
|
|
8099
|
-
*
|
|
8100
|
-
* With NO stamp (undefined/empty) the source-re-read classification runs
|
|
8101
|
-
* exactly as in #1205a:
|
|
8102
|
-
* - No `adk:` scope tag across readable sources → NON-ADK candidate; return
|
|
8103
|
-
* the provenance tags only (unchanged behavior).
|
|
8104
|
-
* - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
|
|
8105
|
-
* return [scopeTag, ...provenance].
|
|
8106
|
-
* - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
|
|
8107
|
-
* OR incomplete (some source unreadable) → REFUSE (fail-closed): a
|
|
8108
|
-
* tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
|
|
8109
|
-
* not a benign miss.
|
|
8110
|
-
*/
|
|
8111
|
-
export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
|
|
8112
|
-
const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
|
|
8113
|
-
// #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
|
|
8114
|
-
// re-read sources. This is the seam closure: correctness no longer depends on
|
|
8115
|
-
// source readability. `adkSourced` (which gates the Soul-promotion refusal in
|
|
8116
|
-
// the promote action) tracks whether the stamped tag is an ADK scope tag.
|
|
8117
|
-
if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
|
|
8118
|
-
return {
|
|
8119
|
-
ok: true,
|
|
8120
|
-
tags: [stampedScopeTag, ...provenance],
|
|
8121
|
-
adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
|
|
8122
|
-
};
|
|
8123
|
-
}
|
|
8124
|
-
const adkTags = new Set();
|
|
8125
|
-
let anySourceUnreadable = false;
|
|
8126
|
-
for (const s of sources) {
|
|
8127
|
-
if (!s.ok) {
|
|
8128
|
-
anySourceUnreadable = true;
|
|
8129
|
-
continue;
|
|
8130
|
-
}
|
|
8131
|
-
for (const t of s.tags) {
|
|
8132
|
-
if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
|
|
8133
|
-
adkTags.add(t);
|
|
8134
|
-
}
|
|
8135
|
-
}
|
|
8136
|
-
// No positive ADK evidence → non-ADK. An unreadable source with zero ADK
|
|
8137
|
-
// evidence does NOT fail closed here (that would regress non-ADK promotion);
|
|
8138
|
-
// see the SEAM note above.
|
|
8139
|
-
if (adkTags.size === 0) {
|
|
8140
|
-
return { ok: true, tags: provenance, adkSourced: false };
|
|
8141
|
-
}
|
|
8142
|
-
if (adkTags.size > 1) {
|
|
8143
|
-
return {
|
|
8144
|
-
ok: false,
|
|
8145
|
-
reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
|
|
8146
|
-
};
|
|
8147
|
-
}
|
|
8148
|
-
if (anySourceUnreadable) {
|
|
8149
|
-
return {
|
|
8150
|
-
ok: false,
|
|
8151
|
-
reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
|
|
8152
|
-
};
|
|
8153
|
-
}
|
|
8154
|
-
const scopeTag = [...adkTags][0];
|
|
8155
|
-
return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
|
|
8156
|
-
}
|
|
8157
|
-
// ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
|
|
8158
|
-
// Continuity-journal scope tag prefix. Canonical string duplicated in
|
|
8159
|
-
// resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
|
|
8160
|
-
// packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
|
|
8161
|
-
// npm-packaging boundary (see this file's header) and cannot import them; kept
|
|
8162
|
-
// in sync by the shared canonical string, same discipline as
|
|
8163
|
-
// MACHINE_REVIEWER_* below.
|
|
8164
|
-
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
8165
|
-
/**
|
|
8166
|
-
* Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
|
|
8167
|
-
* (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
|
|
8168
|
-
* decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
|
|
8169
|
-
* default-private-unless ruling covers BOTH promotion paths: the sources of a
|
|
8170
|
-
* continuity candidate are the most sensitive tier (ephemeral+private journal
|
|
8171
|
-
* rows), so leaving visibility unset here would let Memory's durability-keyed
|
|
8172
|
-
* default widen it to shared ("persistent" defaults shared) — a silent
|
|
8173
|
-
* visibility escalation. "shared" only when the candidate is continuity-scoped
|
|
8174
|
-
* AND carries the distiller's affirmative ruling WITH its recorded
|
|
8175
|
-
* team-relevance justification; every other case — including every
|
|
8176
|
-
* uncertainty — is "private".
|
|
8177
|
-
*
|
|
8178
|
-
* Returns undefined for NON-continuity candidates: their visibility behavior
|
|
8179
|
-
* (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
|
|
8180
|
-
* deliberately not changed here.
|
|
8181
|
-
*/
|
|
8182
|
-
export function derivePromotedVisibility(candidate) {
|
|
8183
|
-
const scopeTag = candidate.scopeTag;
|
|
8184
|
-
const isContinuity = typeof scopeTag === "string" &&
|
|
8185
|
-
scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
|
|
8186
|
-
scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
8187
|
-
if (!isContinuity)
|
|
8188
|
-
return undefined;
|
|
8189
|
-
if (candidate.visibilityRuling !== "shared")
|
|
8190
|
-
return "private";
|
|
8191
|
-
const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
|
|
8192
|
-
return rationale.length > 0 ? "shared" : "private";
|
|
8193
|
-
}
|
|
8194
|
-
// ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
|
|
8195
|
-
// A promotion records a reviewerId that feeds audit/attribution
|
|
8196
|
-
// (schemas/memory.graphql:209). An automated (machine-driven) promotion path
|
|
8197
|
-
// must record a reviewerId that can NEVER be mistaken for a human/agent
|
|
8198
|
-
// reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
|
|
8199
|
-
// for that, and forbid the human `--reviewer` path from claiming it.
|
|
8200
|
-
export const MACHINE_REVIEWER_PREFIX = "machine:";
|
|
8201
|
-
/** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
|
|
8202
|
-
export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
|
|
8203
|
-
/** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
|
|
8204
|
-
* denotes an automated path, not a human or agent reviewer. */
|
|
8205
|
-
export function isMachineReviewerId(id) {
|
|
8206
|
-
return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
|
|
8207
|
-
}
|
|
8208
|
-
/** The human `flair rem promote` path must not record a reviewerId in the
|
|
8209
|
-
* reserved machine namespace — that would launder automated attribution onto
|
|
8210
|
-
* a human-operated promotion. Returns an error string, or null if allowed. */
|
|
8211
|
-
export function validateHumanReviewerId(reviewerId) {
|
|
8212
|
-
if (isMachineReviewerId(reviewerId)) {
|
|
8213
|
-
return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
|
|
8214
|
-
}
|
|
8215
|
-
return null;
|
|
8216
|
-
}
|
|
8082
|
+
import { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, validateHumanReviewerId } from "./rem/promote-policy.js";
|
|
8083
|
+
export { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, derivePromotedVisibility, validateHumanReviewerId, isMachineReviewerId, ADK_SCOPE_TAG_PREFIX, CONTINUITY_SCOPE_TAG_PREFIX, MACHINE_REVIEWER_PREFIX, MACHINE_REVIEWER_ADK_AUTO_PROMOTE } from "./rem/promote-policy.js";
|
|
8217
8084
|
// ─── flair rem promote ───────────────────────────────────────────────────────
|
|
8218
8085
|
// Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
|
|
8219
8086
|
// or persistent Memory. Both --rationale and --to are required (spec § 5: no
|
|
@@ -8249,6 +8116,17 @@ rem
|
|
|
8249
8116
|
process.exit(1);
|
|
8250
8117
|
}
|
|
8251
8118
|
try {
|
|
8119
|
+
if (opts.to === "memory") {
|
|
8120
|
+
const promoted = await api("POST", "/PromoteMemoryCandidate", {
|
|
8121
|
+
candidateId, rationale: opts.rationale,
|
|
8122
|
+
...(opts.reviewer ? { reviewerId: opts.reviewer } : {}),
|
|
8123
|
+
});
|
|
8124
|
+
if (promoted?.error)
|
|
8125
|
+
throw new Error(promoted.error);
|
|
8126
|
+
console.log(`✅ Wrote Memory ${promoted.memoryId} (durability=persistent)`);
|
|
8127
|
+
console.log(`✅ Candidate ${candidateId} marked promoted → memory, reviewer=${promoted.reviewerId}`);
|
|
8128
|
+
return;
|
|
8129
|
+
}
|
|
8252
8130
|
// Fetch the candidate
|
|
8253
8131
|
const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
|
|
8254
8132
|
const candidateData = (candidate && !candidate.error) ? candidate : null;
|
|
@@ -8295,63 +8173,31 @@ rem
|
|
|
8295
8173
|
}
|
|
8296
8174
|
// Soul entries are agentId-scoped and cannot carry a per-user scope tag,
|
|
8297
8175
|
// so an ADK-sourced candidate promoted to Soul is a cross-user leak by
|
|
8298
|
-
// construction — fail closed
|
|
8299
|
-
//
|
|
8300
|
-
// foundation.)
|
|
8176
|
+
// construction — fail closed here and again on Soul.post/put so a
|
|
8177
|
+
// scripted PUT /Soul cannot bypass the CLI.
|
|
8301
8178
|
if (opts.to === "soul" && tagDecision.adkSourced) {
|
|
8302
8179
|
console.error(`Error: candidate ${candidateId} is ADK-sourced (scope tag ${tagDecision.tags[0]}); Soul is agentId-scoped and cannot carry a per-user scope tag — refusing to promote to Soul (would leak across users). Promote ADK-sourced candidates to memory.`);
|
|
8303
8180
|
process.exit(1);
|
|
8304
8181
|
}
|
|
8305
8182
|
const promotedTags = tagDecision.tags;
|
|
8306
8183
|
const decidedAt = new Date().toISOString();
|
|
8307
|
-
//
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
...(promotedVisibility ? { visibility: promotedVisibility } : {}),
|
|
8323
|
-
tags: promotedTags,
|
|
8324
|
-
derivedFrom: candidate.sourceMemoryIds ?? [],
|
|
8325
|
-
promotionStatus: "approved",
|
|
8326
|
-
promotedAt: decidedAt,
|
|
8327
|
-
promotedBy: reviewerId,
|
|
8328
|
-
createdAt: decidedAt,
|
|
8329
|
-
});
|
|
8330
|
-
if (memWrite?.error) {
|
|
8331
|
-
console.error(`Error writing Memory: ${memWrite.error}`);
|
|
8332
|
-
process.exit(1);
|
|
8333
|
-
}
|
|
8334
|
-
console.log(`✅ Wrote Memory ${memId} (durability=persistent)`);
|
|
8335
|
-
}
|
|
8336
|
-
else {
|
|
8337
|
-
// soul
|
|
8338
|
-
const soulId = `${candidate.agentId}-${opts.key}`;
|
|
8339
|
-
const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
|
|
8340
|
-
id: soulId,
|
|
8341
|
-
agentId: candidate.agentId,
|
|
8342
|
-
key: opts.key,
|
|
8343
|
-
value: candidate.claim,
|
|
8344
|
-
priority: "standard",
|
|
8345
|
-
durability: "persistent",
|
|
8346
|
-
createdAt: decidedAt,
|
|
8347
|
-
updatedAt: decidedAt,
|
|
8348
|
-
});
|
|
8349
|
-
if (soulWrite?.error) {
|
|
8350
|
-
console.error(`Error writing Soul: ${soulWrite.error}`);
|
|
8351
|
-
process.exit(1);
|
|
8352
|
-
}
|
|
8353
|
-
console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
|
|
8184
|
+
// Memory promotion is handled by the server workflow above.
|
|
8185
|
+
const soulId = `${candidate.agentId}-${opts.key}`;
|
|
8186
|
+
const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
|
|
8187
|
+
id: soulId,
|
|
8188
|
+
agentId: candidate.agentId,
|
|
8189
|
+
key: opts.key,
|
|
8190
|
+
value: candidate.claim,
|
|
8191
|
+
priority: "standard",
|
|
8192
|
+
durability: "persistent",
|
|
8193
|
+
createdAt: decidedAt,
|
|
8194
|
+
updatedAt: decidedAt,
|
|
8195
|
+
});
|
|
8196
|
+
if (soulWrite?.error) {
|
|
8197
|
+
console.error(`Error writing Soul: ${soulWrite.error}`);
|
|
8198
|
+
process.exit(1);
|
|
8354
8199
|
}
|
|
8200
|
+
console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
|
|
8355
8201
|
// Update the candidate row
|
|
8356
8202
|
const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
|
|
8357
8203
|
...candidate,
|
|
@@ -8524,6 +8370,9 @@ remNightly
|
|
|
8524
8370
|
.command("run-once")
|
|
8525
8371
|
.description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
|
|
8526
8372
|
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
8373
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
8374
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
8375
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
8527
8376
|
.option("--dry-run", "Log the row but skip the snapshot write")
|
|
8528
8377
|
.action(async (opts) => {
|
|
8529
8378
|
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
@@ -8532,12 +8381,47 @@ remNightly
|
|
|
8532
8381
|
process.exit(1);
|
|
8533
8382
|
}
|
|
8534
8383
|
const { runNightlyCycle } = await import("./rem/runner.js");
|
|
8384
|
+
// The runner is agent-authed and cannot reach the ops port itself. When
|
|
8385
|
+
// admin credentials are available, inject an ops-API `search_by_conditions`
|
|
8386
|
+
// helper so the pending-candidate count can be sampled; otherwise the count
|
|
8387
|
+
// is best-effort 0 (the cycle still runs).
|
|
8388
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8389
|
+
const opsSearch = adminPass
|
|
8390
|
+
? async (table, conditions, getAttributes) => {
|
|
8391
|
+
const opsPort = resolveOpsPort(opts);
|
|
8392
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
8393
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
8394
|
+
method: "POST",
|
|
8395
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
8396
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions, get_attributes: getAttributes }),
|
|
8397
|
+
});
|
|
8398
|
+
if (!res.ok)
|
|
8399
|
+
throw new Error(`ops API failed (${res.status})`);
|
|
8400
|
+
const raw = await res.json();
|
|
8401
|
+
return Array.isArray(raw) ? raw : (raw?.results ?? []);
|
|
8402
|
+
}
|
|
8403
|
+
: undefined;
|
|
8535
8404
|
try {
|
|
8405
|
+
const healthBase = (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`).replace(/\/+$/, "");
|
|
8536
8406
|
const result = await runNightlyCycle({
|
|
8537
8407
|
agentId,
|
|
8538
8408
|
flairVersion: __pkgVersion,
|
|
8539
8409
|
apiCall: api,
|
|
8410
|
+
opsSearch,
|
|
8540
8411
|
dryRun: !!opts.dryRun,
|
|
8412
|
+
healthProbe: async (timeoutMs) => {
|
|
8413
|
+
const t = Date.now();
|
|
8414
|
+
try {
|
|
8415
|
+
const res = await fetch(`${healthBase}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
8416
|
+
if (!res.ok) {
|
|
8417
|
+
return { ok: false, elapsedMs: Date.now() - t, error: `GET /Health returned HTTP ${res.status}` };
|
|
8418
|
+
}
|
|
8419
|
+
return { ok: true, elapsedMs: Date.now() - t };
|
|
8420
|
+
}
|
|
8421
|
+
catch (err) {
|
|
8422
|
+
return { ok: false, elapsedMs: Date.now() - t, error: err?.message ?? String(err) };
|
|
8423
|
+
}
|
|
8424
|
+
},
|
|
8541
8425
|
});
|
|
8542
8426
|
const row = result.logRow;
|
|
8543
8427
|
console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
|
|
@@ -8558,6 +8442,12 @@ remNightly
|
|
|
8558
8442
|
if (row.candidates) {
|
|
8559
8443
|
console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
|
|
8560
8444
|
}
|
|
8445
|
+
if (row.distill) {
|
|
8446
|
+
const remaining = Math.max(0, row.distill.unreflected - row.distill.gathered);
|
|
8447
|
+
console.log(`Distilled: ${row.distill.gathered} memor${row.distill.gathered === 1 ? "y" : "ies"} (cap ${row.distill.maxMemories}; ${remaining} unreflected remaining)`);
|
|
8448
|
+
if (row.distill.aborted)
|
|
8449
|
+
console.log(`Aborted: yes — in-flight distillation stopped (flair rem pause)`);
|
|
8450
|
+
}
|
|
8561
8451
|
// row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
|
|
8562
8452
|
// this cycle — i.e. a non-dry-run cycle for an ADK agentId.
|
|
8563
8453
|
if (row.autoPromoted) {
|
|
@@ -8570,6 +8460,10 @@ remNightly
|
|
|
8570
8460
|
console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
|
|
8571
8461
|
}
|
|
8572
8462
|
console.log(`Duration: ${row.durationMs}ms`);
|
|
8463
|
+
if (result.status === "refused") {
|
|
8464
|
+
console.log(`\nNote: REM refused to start because /Health could not be served.`);
|
|
8465
|
+
console.log(`Restore /Health before retrying, or \`flair rem pause\` to stop the scheduler.`);
|
|
8466
|
+
}
|
|
8573
8467
|
if (row.errors.length > 0) {
|
|
8574
8468
|
console.log(`Errors:`);
|
|
8575
8469
|
for (const e of row.errors)
|
|
@@ -8635,8 +8529,7 @@ remSnapshot
|
|
|
8635
8529
|
//
|
|
8636
8530
|
// The <date> argument is an ISO-timestamp prefix or date-only prefix; the
|
|
8637
8531
|
// command picks the latest snapshot matching that prefix.
|
|
8638
|
-
rem
|
|
8639
|
-
.command("restore <date>")
|
|
8532
|
+
addSharedCredentialOptions(rem.command("restore <date>"))
|
|
8640
8533
|
.description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
|
|
8641
8534
|
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
8642
8535
|
.option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
|
|
@@ -8674,12 +8567,24 @@ rem
|
|
|
8674
8567
|
// --apply path: live replay via src/rem/restore.ts
|
|
8675
8568
|
if (opts.apply) {
|
|
8676
8569
|
const { applySnapshot } = await import("./rem/restore.js");
|
|
8570
|
+
applyAdminPassFile(opts);
|
|
8571
|
+
const restoreBase = process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`;
|
|
8572
|
+
const adminPass = opts.dryRun ? undefined : resolveLocalAdminPass(opts.adminPass, !isLocalBase(restoreBase));
|
|
8573
|
+
if (!opts.dryRun && !adminPass) {
|
|
8574
|
+
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for rem restore --apply " +
|
|
8575
|
+
"(Soul rewrite is operator-only; an agent key is refused).");
|
|
8576
|
+
process.exit(1);
|
|
8577
|
+
}
|
|
8578
|
+
const soulApiCall = adminPass
|
|
8579
|
+
? (method, path, body) => api(method, path, body, { explicitAdminPass: adminPass, adminUser: opts.adminUser, agentId: null })
|
|
8580
|
+
: undefined;
|
|
8677
8581
|
try {
|
|
8678
8582
|
const result = await applySnapshot({
|
|
8679
8583
|
agentId,
|
|
8680
8584
|
snapshotPath: match.path,
|
|
8681
8585
|
flairVersion: __pkgVersion,
|
|
8682
8586
|
apiCall: api,
|
|
8587
|
+
soulApiCall,
|
|
8683
8588
|
dryRun: !!opts.dryRun,
|
|
8684
8589
|
});
|
|
8685
8590
|
const verb = opts.dryRun ? "(dry-run) would" : "";
|
|
@@ -8690,7 +8595,7 @@ rem
|
|
|
8690
8595
|
console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
|
|
8691
8596
|
console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
|
|
8692
8597
|
}
|
|
8693
|
-
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
|
|
8598
|
+
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls, ${result.deleted.candidates} candidates`);
|
|
8694
8599
|
console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
|
|
8695
8600
|
if (result.errors.length > 0) {
|
|
8696
8601
|
console.log(` Errors:`);
|
|
@@ -8737,16 +8642,32 @@ rem
|
|
|
8737
8642
|
// Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
|
|
8738
8643
|
// nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
|
|
8739
8644
|
// honored — lets ops pause fleet-wide without writing a file.
|
|
8645
|
+
// #1515: the same sentinel aborts an in-flight /ReflectMemories gather on
|
|
8646
|
+
// the Harper host (checked between yield points) so an operator can stop a
|
|
8647
|
+
// runaway run without restarting Harper.
|
|
8740
8648
|
const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
8741
|
-
|
|
8742
|
-
.command("pause")
|
|
8743
|
-
.description("Pause nightly REM runs — writes ~/.flair/rem.paused sentinel")
|
|
8744
|
-
.action(() => {
|
|
8649
|
+
function writeRemPauseSentinel() {
|
|
8745
8650
|
const dir = dirname(REM_PAUSE_FLAG);
|
|
8746
8651
|
if (!existsSync(dir))
|
|
8747
8652
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8748
8653
|
writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
|
|
8654
|
+
}
|
|
8655
|
+
rem
|
|
8656
|
+
.command("pause")
|
|
8657
|
+
.description("Pause nightly REM runs and abort an in-flight distillation gather")
|
|
8658
|
+
.action(() => {
|
|
8659
|
+
writeRemPauseSentinel();
|
|
8749
8660
|
console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
|
|
8661
|
+
console.log(` In-flight distillation will abort at the next yield.`);
|
|
8662
|
+
console.log(` Resume with: flair rem resume`);
|
|
8663
|
+
});
|
|
8664
|
+
rem
|
|
8665
|
+
.command("abort")
|
|
8666
|
+
.description("Abort an in-flight REM distillation (same sentinel as pause)")
|
|
8667
|
+
.action(() => {
|
|
8668
|
+
writeRemPauseSentinel();
|
|
8669
|
+
console.log(`✅ REM abort requested (sentinel: ${REM_PAUSE_FLAG})`);
|
|
8670
|
+
console.log(` In-flight distillation will stop at the next yield; the scheduler stays paused.`);
|
|
8750
8671
|
console.log(` Resume with: flair rem resume`);
|
|
8751
8672
|
});
|
|
8752
8673
|
rem
|
|
@@ -8890,7 +8811,7 @@ export async function discoverLocalFlairPort(originalUrl) {
|
|
|
8890
8811
|
// pass it in, so this verified read signs as the SAME agent the rest of the
|
|
8891
8812
|
// command does. Undefined = resolve locally via the legacy flag>env pair (all
|
|
8892
8813
|
// other callers, unchanged).
|
|
8893
|
-
async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
8814
|
+
async function fetchHealthDetail(opts, signingAgentIdOverride, signingAgentSource) {
|
|
8894
8815
|
const port = resolveHttpPort(opts);
|
|
8895
8816
|
// --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
|
|
8896
8817
|
const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
|
|
@@ -8926,6 +8847,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
|
8926
8847
|
agentId: signingAgentIdOverride !== undefined
|
|
8927
8848
|
? (signingAgentIdOverride ?? undefined)
|
|
8928
8849
|
: (opts.agent || process.env.FLAIR_AGENT_ID),
|
|
8850
|
+
agentIdSource: signingAgentSource,
|
|
8929
8851
|
});
|
|
8930
8852
|
}
|
|
8931
8853
|
catch {
|
|
@@ -8944,8 +8866,8 @@ const statusCmd = program
|
|
|
8944
8866
|
.option("--json", "Output as JSON")
|
|
8945
8867
|
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
8946
8868
|
.action(async (opts) => {
|
|
8947
|
-
const statusAgentId = resolveSigningAgentId(opts, "status");
|
|
8948
|
-
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
|
|
8869
|
+
const { agentId: statusAgentId, source: statusSource } = resolveSigningAgentId(opts, "status");
|
|
8870
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId, statusSource);
|
|
8949
8871
|
// When unreachable on a localhost URL, probe candidate ports to detect
|
|
8950
8872
|
// config-vs-daemon port drift. Surface the actually-listening
|
|
8951
8873
|
// port with a fix recipe — better UX than just "unreachable."
|
|
@@ -10292,6 +10214,7 @@ program
|
|
|
10292
10214
|
.command("upgrade")
|
|
10293
10215
|
.description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
|
|
10294
10216
|
.option("--check", "Only check for updates / show the plan, don't install or deploy")
|
|
10217
|
+
.option("--tree <dir>", "Upgrade this extracted package tree in place (npm pack / plain-tree lane). Default: the serving instance's packed tree when that is not the npm-global install")
|
|
10295
10218
|
.option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
|
|
10296
10219
|
.option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
|
|
10297
10220
|
.option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
|
|
@@ -10313,7 +10236,7 @@ program
|
|
|
10313
10236
|
// A colliding name is normally recoverable via optsWithGlobals(); this one is
|
|
10314
10237
|
// not, because commander's version listener exits the process. The name had
|
|
10315
10238
|
// to change. `--harper-version` below is the symmetry this follows.
|
|
10316
|
-
.option("--flair-version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
|
|
10239
|
+
.option("--flair-version <semver>", "Flair version to deploy with --target, or to pin the plain-tree tarball swap (default: latest published @tpsdev-ai/flair)")
|
|
10317
10240
|
.option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
|
|
10318
10241
|
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
10319
10242
|
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
@@ -10338,6 +10261,48 @@ program
|
|
|
10338
10261
|
const checkOnly = opts.check ?? false;
|
|
10339
10262
|
const showAll = opts.all ?? false;
|
|
10340
10263
|
console.log("Checking for updates...\n");
|
|
10264
|
+
// flair#1109 (a): if the serving tree (or --tree) is a packed extract,
|
|
10265
|
+
// take the in-place tarball lane instead of upgrading a leftover
|
|
10266
|
+
// npm-global relic. (b) still probes — and still prints — when we are
|
|
10267
|
+
// not taking that lane (git checkout, unknown path). Detection is
|
|
10268
|
+
// best-effort and never fails the command except an explicit --tree
|
|
10269
|
+
// that does not name a packed install (refuse, don't silently fall through).
|
|
10270
|
+
const upgradeServingPid = resolveInstanceServingPid(defaultDataDir(), resolveHttpPort({}));
|
|
10271
|
+
const upgradeNpmPrefix = await resolveNpmGlobalPrefix();
|
|
10272
|
+
let treeDecision = { kind: "skip" };
|
|
10273
|
+
try {
|
|
10274
|
+
treeDecision = resolvePlainTreeTarget({
|
|
10275
|
+
treeFlag: typeof opts.tree === "string" && opts.tree.trim() !== "" ? opts.tree.trim() : null,
|
|
10276
|
+
serving: upgradeServingPid != null ? resolveServingFlairPackage(upgradeServingPid) : null,
|
|
10277
|
+
cli: findFlairPackageDir(flairPackageDir()),
|
|
10278
|
+
global: resolveNpmGlobalFlairPackage(upgradeNpmPrefix, process.platform),
|
|
10279
|
+
});
|
|
10280
|
+
}
|
|
10281
|
+
catch { /* treat as skip — never fail the probe */ }
|
|
10282
|
+
if (treeDecision.kind === "refuse") {
|
|
10283
|
+
console.error(`❌ ${treeDecision.message}`);
|
|
10284
|
+
process.exit(1);
|
|
10285
|
+
}
|
|
10286
|
+
const treeLane = treeDecision.kind === "use" ? treeDecision.inspection : null;
|
|
10287
|
+
// flair#1109 (b): print the mismatch warning only when this run will
|
|
10288
|
+
// still treat npm-global as the install. Collect always, so the (b)
|
|
10289
|
+
// wiring test keeps seeing the call.
|
|
10290
|
+
try {
|
|
10291
|
+
const execPathWarning = collectUpgradeExecPathWarning({
|
|
10292
|
+
servingPid: upgradeServingPid,
|
|
10293
|
+
cliPackageDir: flairPackageDir(),
|
|
10294
|
+
npmGlobalPrefix: upgradeNpmPrefix,
|
|
10295
|
+
});
|
|
10296
|
+
if (execPathWarning && !treeLane) {
|
|
10297
|
+
console.log(execPathWarning);
|
|
10298
|
+
console.log("");
|
|
10299
|
+
}
|
|
10300
|
+
}
|
|
10301
|
+
catch { /* never fail upgrade over a path probe */ }
|
|
10302
|
+
if (treeLane) {
|
|
10303
|
+
console.log(formatPlainTreeBanner(treeLane));
|
|
10304
|
+
console.log("");
|
|
10305
|
+
}
|
|
10341
10306
|
const packages = [
|
|
10342
10307
|
{
|
|
10343
10308
|
name: "@tpsdev-ai/flair",
|
|
@@ -10380,11 +10345,33 @@ program
|
|
|
10380
10345
|
if (transitive && !showAll)
|
|
10381
10346
|
continue;
|
|
10382
10347
|
try {
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10348
|
+
let registryLatest = null;
|
|
10349
|
+
try {
|
|
10350
|
+
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
10351
|
+
if (res.ok) {
|
|
10352
|
+
const data = await res.json();
|
|
10353
|
+
registryLatest = typeof data.version === "string" && data.version ? data.version : null;
|
|
10354
|
+
}
|
|
10355
|
+
}
|
|
10356
|
+
catch { /* /latest timed out or failed — pin path must still work */ }
|
|
10357
|
+
let latest;
|
|
10358
|
+
if (treeLane && name === FLAIR_PKG_NAME) {
|
|
10359
|
+
// Consult registry latest, then apply --flair-version as the swap
|
|
10360
|
+
// target. A pin still applies when /latest is unavailable; without
|
|
10361
|
+
// that, a requested tarball swap reports up to date and does nothing.
|
|
10362
|
+
const listing = resolvePlainTreeListingTarget({
|
|
10363
|
+
registryLatest,
|
|
10364
|
+
pin: typeof opts.flairVersion === "string" ? opts.flairVersion : null,
|
|
10365
|
+
});
|
|
10366
|
+
if (!listing)
|
|
10367
|
+
continue;
|
|
10368
|
+
latest = listing.version;
|
|
10369
|
+
}
|
|
10370
|
+
else {
|
|
10371
|
+
if (!registryLatest)
|
|
10372
|
+
continue;
|
|
10373
|
+
latest = registryLatest;
|
|
10374
|
+
}
|
|
10388
10375
|
if (name === FLAIR_PKG_NAME && latest !== "unknown") {
|
|
10389
10376
|
try {
|
|
10390
10377
|
primeVersionCheckCache(latest);
|
|
@@ -10394,7 +10381,18 @@ program
|
|
|
10394
10381
|
const globalProbe = probe();
|
|
10395
10382
|
let installed;
|
|
10396
10383
|
let status;
|
|
10397
|
-
if (name ===
|
|
10384
|
+
if (treeLane && name === FLAIR_PKG_NAME) {
|
|
10385
|
+
// The serving/CLI packed tree is the install. A PATH or
|
|
10386
|
+
// require.resolve probe would report the npm-global relic.
|
|
10387
|
+
installed = treeLane.version;
|
|
10388
|
+
if (installed === null)
|
|
10389
|
+
status = "missing";
|
|
10390
|
+
else if (installed === latest)
|
|
10391
|
+
status = "current";
|
|
10392
|
+
else
|
|
10393
|
+
status = "outdated";
|
|
10394
|
+
}
|
|
10395
|
+
else if (name === FLAIR_MCP_PACKAGE) {
|
|
10398
10396
|
// flair-mcp is zero-install via npx (#1168) — a null global probe is
|
|
10399
10397
|
// the NORMAL state, not "missing". Resolve it from its actual wiring
|
|
10400
10398
|
// (the pin in a client MCP config / the SessionStart hook) so the
|
|
@@ -10436,7 +10434,12 @@ program
|
|
|
10436
10434
|
}
|
|
10437
10435
|
// Scope footer: make explicit what `flair upgrade` does and
|
|
10438
10436
|
// doesn't cover, so "were the others checked?" has a one-line answer.
|
|
10439
|
-
|
|
10437
|
+
if (treeLane) {
|
|
10438
|
+
console.log(`\n${formatPlainTreeScopeFooter(treeLane)}`);
|
|
10439
|
+
}
|
|
10440
|
+
else {
|
|
10441
|
+
console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
|
|
10442
|
+
}
|
|
10440
10443
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
10441
10444
|
const missing = findings.filter((f) => f.status === "missing");
|
|
10442
10445
|
// flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
|
|
@@ -10457,6 +10460,20 @@ program
|
|
|
10457
10460
|
.filter((f) => f.kind === "openclaw-plugin")
|
|
10458
10461
|
.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
|
|
10459
10462
|
const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
|
|
10463
|
+
let treePlan = null;
|
|
10464
|
+
if (treeLane) {
|
|
10465
|
+
const flairFindingForPlan = findings.find((f) => f.name === FLAIR_PKG_NAME);
|
|
10466
|
+
treePlan = planPlainTreeUpgrade({
|
|
10467
|
+
treeDir: treeLane.dir,
|
|
10468
|
+
fromVersion: treeLane.version,
|
|
10469
|
+
toVersion: flairFindingForPlan?.latest ?? treeLane.version ?? "unknown",
|
|
10470
|
+
systemdUnits: findSystemdUnitsForTree(treeLane.dir),
|
|
10471
|
+
});
|
|
10472
|
+
if (flairFindingForPlan?.status === "outdated") {
|
|
10473
|
+
console.log("");
|
|
10474
|
+
console.log(formatPlainTreePlan(treePlan));
|
|
10475
|
+
}
|
|
10476
|
+
}
|
|
10460
10477
|
if (outdated.length === 0 && missing.length === 0) {
|
|
10461
10478
|
console.log("\n✅ Everything is up to date.");
|
|
10462
10479
|
return;
|
|
@@ -10515,6 +10532,19 @@ program
|
|
|
10515
10532
|
const result = client.wire(env);
|
|
10516
10533
|
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
10517
10534
|
}
|
|
10535
|
+
// flair#1516: the SessionStart hook command carries the SAME
|
|
10536
|
+
// @tpsdev-ai/flair-mcp@<version> pin as the client MCP block, but only
|
|
10537
|
+
// the client block was refreshed above — so an upgraded user kept
|
|
10538
|
+
// launching the PREVIOUS adapter on every session, silently, while
|
|
10539
|
+
// `flair doctor` reported the hook "still runs". Re-pin every ALREADY-
|
|
10540
|
+
// wired hook to the current spec too (never adds one — that stays an
|
|
10541
|
+
// opt-in). Best-effort, same as the client refresh.
|
|
10542
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
10543
|
+
const repin = repinSessionStartHook(homedir(), harness);
|
|
10544
|
+
if (repin.action === "update") {
|
|
10545
|
+
console.log(` ${repin.ok ? "✓" : "•"} ${repin.message}`);
|
|
10546
|
+
}
|
|
10547
|
+
}
|
|
10518
10548
|
}
|
|
10519
10549
|
// Nothing to install via npm/openclaw. What is left is advisory (packages
|
|
10520
10550
|
// not detected) and/or a flair-mcp whose wired pin is behind latest. The
|
|
@@ -10548,7 +10578,10 @@ program
|
|
|
10548
10578
|
return;
|
|
10549
10579
|
}
|
|
10550
10580
|
if (checkOnly) {
|
|
10551
|
-
|
|
10581
|
+
const treeHint = typeof opts.tree === "string" && opts.tree.trim() !== ""
|
|
10582
|
+
? ` --tree ${opts.tree.trim()}`
|
|
10583
|
+
: treeLane ? ` --tree ${treeLane.dir}` : "";
|
|
10584
|
+
console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade${treeHint}`);
|
|
10552
10585
|
if (missing.length > 0) {
|
|
10553
10586
|
console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
|
|
10554
10587
|
}
|
|
@@ -10662,7 +10695,7 @@ program
|
|
|
10662
10695
|
let currentEngineVersion = null;
|
|
10663
10696
|
let targetEngineVersion = null;
|
|
10664
10697
|
if (flairIsUpgrading && hasDataDir) {
|
|
10665
|
-
currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
|
|
10698
|
+
currentEngineVersion = readInstalledHarperVersion(treeLane?.dir ?? flairPackageDir());
|
|
10666
10699
|
const targetFlairVersion = flairFinding?.latest;
|
|
10667
10700
|
if (targetFlairVersion && currentEngineVersion) {
|
|
10668
10701
|
targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
|
|
@@ -10723,6 +10756,12 @@ program
|
|
|
10723
10756
|
let flairInstallFailed = false;
|
|
10724
10757
|
for (const { pkg, latest } of npmUpgrades) {
|
|
10725
10758
|
try {
|
|
10759
|
+
if (treePlan && pkg === FLAIR_PKG_NAME) {
|
|
10760
|
+
console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
|
|
10761
|
+
await applyPlainTreeUpgrade(treePlan);
|
|
10762
|
+
console.log(` ✅ ${pkg}@${latest} installed (plain-tree swap; previous tree at ${treePlan.previousDir})`);
|
|
10763
|
+
continue;
|
|
10764
|
+
}
|
|
10726
10765
|
console.log(` Installing ${pkg}@${latest}...`);
|
|
10727
10766
|
execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
|
|
10728
10767
|
console.log(` ✅ ${pkg}@${latest} installed`);
|
|
@@ -10785,6 +10824,9 @@ program
|
|
|
10785
10824
|
}
|
|
10786
10825
|
if (!shouldRestart) {
|
|
10787
10826
|
console.log("\nRun: flair restart to use the new version");
|
|
10827
|
+
if (treePlan) {
|
|
10828
|
+
console.log(`Previous tree kept at ${treePlan.previousDir} until you restart and verify.`);
|
|
10829
|
+
}
|
|
10788
10830
|
return;
|
|
10789
10831
|
}
|
|
10790
10832
|
console.log("\nRestarting Flair...");
|
|
@@ -10805,12 +10847,31 @@ program
|
|
|
10805
10847
|
const rollbackTo = async (toVersion, reason) => {
|
|
10806
10848
|
console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
|
|
10807
10849
|
try {
|
|
10808
|
-
|
|
10850
|
+
if (treePlan) {
|
|
10851
|
+
const rollbackDecision = decidePlainTreeRollback(existsSync(treePlan.previousDir));
|
|
10852
|
+
if (rollbackDecision.kind === "restore") {
|
|
10853
|
+
if (!restorePlainTreePrevious(treePlan)) {
|
|
10854
|
+
throw new Error(`no previous tree at ${treePlan.previousDir} to restore`);
|
|
10855
|
+
}
|
|
10856
|
+
console.log(` ✅ restored previous tree from ${treePlan.previousDir}`);
|
|
10857
|
+
}
|
|
10858
|
+
else {
|
|
10859
|
+
console.log(` (${rollbackDecision.reason})`);
|
|
10860
|
+
}
|
|
10861
|
+
}
|
|
10862
|
+
else {
|
|
10863
|
+
execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
|
|
10864
|
+
}
|
|
10809
10865
|
}
|
|
10810
10866
|
catch (err) {
|
|
10811
10867
|
console.error(`❌ rollback install failed: ${err.message}`);
|
|
10812
10868
|
console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
|
|
10813
|
-
|
|
10869
|
+
const prevExists = !!(treePlan && existsSync(treePlan.previousDir));
|
|
10870
|
+
console.error(treePlan
|
|
10871
|
+
? (prevExists
|
|
10872
|
+
? ` Recover by hand: restore ${treePlan.previousDir} to ${treePlan.treeDir} && flair start`
|
|
10873
|
+
: ` The live tree at ${treePlan.treeDir} was not swapped; there is no .upgrade-prev to restore. Start it with: flair start`)
|
|
10874
|
+
: ` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
|
|
10814
10875
|
process.exit(1);
|
|
10815
10876
|
}
|
|
10816
10877
|
// flair#1053: when the engine (Harper) version changed, the pre-upgrade
|
|
@@ -10859,9 +10920,16 @@ program
|
|
|
10859
10920
|
}
|
|
10860
10921
|
// Same post-swap rule as the upgrade restart above: the rolled-back
|
|
10861
10922
|
// version's own CLI is the thing that knows how to start it.
|
|
10862
|
-
const
|
|
10923
|
+
const rolledBackRoot = treePlan?.treeDir ?? flairPackageDir();
|
|
10924
|
+
const rolledBackCli = resolveInstalledFlairCli(rolledBackRoot, toVersion);
|
|
10863
10925
|
try {
|
|
10864
|
-
|
|
10926
|
+
if (treePlan && treePlan.systemdUnits.length > 0) {
|
|
10927
|
+
console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
|
|
10928
|
+
restartSystemdUnits(treePlan.systemdUnits);
|
|
10929
|
+
}
|
|
10930
|
+
else {
|
|
10931
|
+
await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
|
|
10932
|
+
}
|
|
10865
10933
|
}
|
|
10866
10934
|
catch (err) {
|
|
10867
10935
|
console.error(`❌ rollback restart failed: ${err.message}`);
|
|
@@ -10914,9 +10982,10 @@ program
|
|
|
10914
10982
|
// from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
|
|
10915
10983
|
// tree can't be verified) falls back to an in-process restart, announced.
|
|
10916
10984
|
const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
|
|
10985
|
+
const swappedPackageRoot = treePlan?.treeDir ?? flairPackageDir();
|
|
10917
10986
|
let newCli = null;
|
|
10918
10987
|
if (flairWasSwapped) {
|
|
10919
|
-
const resolved = resolveInstalledFlairCli(
|
|
10988
|
+
const resolved = resolveInstalledFlairCli(swappedPackageRoot, expectedFlairVersion);
|
|
10920
10989
|
if (resolved.ok === false) {
|
|
10921
10990
|
console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
|
|
10922
10991
|
}
|
|
@@ -10926,7 +10995,15 @@ program
|
|
|
10926
10995
|
}
|
|
10927
10996
|
let restartWasDelegated = false;
|
|
10928
10997
|
try {
|
|
10929
|
-
|
|
10998
|
+
if (treePlan && treePlan.systemdUnits.length > 0) {
|
|
10999
|
+
console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
|
|
11000
|
+
restartSystemdUnits(treePlan.systemdUnits);
|
|
11001
|
+
restartWasDelegated = true;
|
|
11002
|
+
console.log("✅ Flair restarted (systemd unit)");
|
|
11003
|
+
}
|
|
11004
|
+
else {
|
|
11005
|
+
restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
|
|
11006
|
+
}
|
|
10930
11007
|
}
|
|
10931
11008
|
catch (err) {
|
|
10932
11009
|
console.error(`❌ restart failed: ${err.message}`);
|
|
@@ -10963,6 +11040,9 @@ program
|
|
|
10963
11040
|
const detached = isDetached(management);
|
|
10964
11041
|
if (!shouldVerify) {
|
|
10965
11042
|
console.log(" (--no-verify: skipping post-restart verification)");
|
|
11043
|
+
if (treePlan) {
|
|
11044
|
+
console.log(` Previous tree kept at ${treePlan.previousDir} (rollback source; not discarded without verify).`);
|
|
11045
|
+
}
|
|
10966
11046
|
if (detached) {
|
|
10967
11047
|
for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
|
|
10968
11048
|
console.error(line);
|
|
@@ -10996,6 +11076,8 @@ program
|
|
|
10996
11076
|
toVersion: expectedFlairVersion,
|
|
10997
11077
|
});
|
|
10998
11078
|
printVerifiedSummary(renderVerifiedSummary(verify.version, run));
|
|
11079
|
+
if (treePlan)
|
|
11080
|
+
discardPlainTreePrevious(treePlan.previousDir);
|
|
10999
11081
|
return;
|
|
11000
11082
|
}
|
|
11001
11083
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -11026,6 +11108,8 @@ program
|
|
|
11026
11108
|
console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
|
|
11027
11109
|
console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
|
|
11028
11110
|
console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
|
|
11111
|
+
if (treePlan)
|
|
11112
|
+
discardPlainTreePrevious(treePlan.previousDir);
|
|
11029
11113
|
return;
|
|
11030
11114
|
}
|
|
11031
11115
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -12298,8 +12382,21 @@ program
|
|
|
12298
12382
|
// is actually stamping new writes with.
|
|
12299
12383
|
const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
|
|
12300
12384
|
const EMBEDDING_VARIANT = "searchprefix";
|
|
12385
|
+
// embedding-space-guard slice 1: getModelId() now stamps the ENGINE-QUALIFIED
|
|
12386
|
+
// id `<engine>:<base>[+searchprefix]`. Duplicated as a literal here (separate
|
|
12387
|
+
// build target — see above). A row is CURRENT-SPACE iff its stamp is the
|
|
12388
|
+
// qualified id OR its one-time bare-name equivalent (today's corpus, stamped
|
|
12389
|
+
// before the qualifier). Treat BOTH as current so `--stale-only` never
|
|
12390
|
+
// re-embeds an already-correct bare-stamped row — that would loop forever
|
|
12391
|
+
// (Memory.put re-stamps it QUALIFIED, still "!= bare" under a single-value
|
|
12392
|
+
// check). Keep in lockstep with resources/embeddings-provider.ts's
|
|
12393
|
+
// getModelId()/EMBEDDING_ENGINE and resources/embedding-space-guard.ts's
|
|
12394
|
+
// normalizeStamp().
|
|
12395
|
+
const EMBEDDING_ENGINE = "gguf";
|
|
12301
12396
|
const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
12302
|
-
const
|
|
12397
|
+
const bareCurrentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
|
|
12398
|
+
const currentModel = `${EMBEDDING_ENGINE}:${bareCurrentModel}`;
|
|
12399
|
+
const isCurrentSpace = (stamp) => stamp === currentModel || stamp === bareCurrentModel;
|
|
12303
12400
|
if (agentId) {
|
|
12304
12401
|
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
12305
12402
|
}
|
|
@@ -12358,7 +12455,7 @@ program
|
|
|
12358
12455
|
for (const m of allMemories) {
|
|
12359
12456
|
if (!m.content)
|
|
12360
12457
|
continue;
|
|
12361
|
-
if (staleOnly && m.embeddingModel
|
|
12458
|
+
if (staleOnly && isCurrentSpace(m.embeddingModel))
|
|
12362
12459
|
continue;
|
|
12363
12460
|
const agent = m.agentId || "unknown";
|
|
12364
12461
|
if (!byAgent.has(agent))
|
|
@@ -12463,7 +12560,7 @@ program
|
|
|
12463
12560
|
if (!m.content)
|
|
12464
12561
|
return false;
|
|
12465
12562
|
if (staleOnly)
|
|
12466
|
-
return !m.embeddingModel || m.embeddingModel
|
|
12563
|
+
return !m.embeddingModel || !isCurrentSpace(m.embeddingModel);
|
|
12467
12564
|
return true;
|
|
12468
12565
|
});
|
|
12469
12566
|
const total = candidates.length;
|
|
@@ -13469,170 +13566,13 @@ program
|
|
|
13469
13566
|
}
|
|
13470
13567
|
}
|
|
13471
13568
|
for (const client of detectedClients) {
|
|
13472
|
-
//
|
|
13473
|
-
//
|
|
13474
|
-
//
|
|
13475
|
-
//
|
|
13476
|
-
//
|
|
13477
|
-
|
|
13478
|
-
if (client.kind === "native-extension") {
|
|
13479
|
-
let pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
13480
|
-
// --fix for pi needs no agent id (pi settings carry no env block);
|
|
13481
|
-
// a resolvable id only improves the export hint in the message.
|
|
13482
|
-
const wirePiFix = async (prompt) => {
|
|
13483
|
-
if (dryRun) {
|
|
13484
|
-
console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
|
|
13485
|
-
return;
|
|
13486
|
-
}
|
|
13487
|
-
const proceed = await confirmFix(prompt);
|
|
13488
|
-
if (!proceed) {
|
|
13489
|
-
console.log(` Skipped.`);
|
|
13490
|
-
return;
|
|
13491
|
-
}
|
|
13492
|
-
const hintAgentId = resolveFixAgentId({
|
|
13493
|
-
optsAgent: opts.agent,
|
|
13494
|
-
envAgentId: process.env.FLAIR_AGENT_ID,
|
|
13495
|
-
anyKnownAgentId,
|
|
13496
|
-
keyAgentIds,
|
|
13497
|
-
keysDir: defaultKeysDir(),
|
|
13498
|
-
}) ?? "<your-agent-id>";
|
|
13499
|
-
const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
|
|
13500
|
-
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
13501
|
-
if (wireResult.ok)
|
|
13502
|
-
fixed++;
|
|
13503
|
-
};
|
|
13504
|
-
// (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
|
|
13505
|
-
// "extensions" is silently ignored by pi — the user believes they
|
|
13506
|
-
// are wired while pi registers zero tools. This is the documented
|
|
13507
|
-
// field failure mode and must never fold into a generic "not
|
|
13508
|
-
// wired": the fix is a MOVE to "packages", not an add.
|
|
13509
|
-
const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
|
|
13510
|
-
const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
|
|
13511
|
-
for (const bad of pi.misconfigured) {
|
|
13512
|
-
console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} is listed under "extensions" as an npm: spec (${bad.entry}) in ${render.wrap(render.c.dim, bad.path)}`);
|
|
13513
|
-
console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
|
|
13514
|
-
issues++;
|
|
13515
|
-
}
|
|
13516
|
-
if (userTraps.length > 0) {
|
|
13517
|
-
if (autoFix) {
|
|
13518
|
-
await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
|
|
13519
|
-
// Re-derive the wiring from disk so the sections below reason
|
|
13520
|
-
// about the POST-fix state — otherwise a move that just
|
|
13521
|
-
// succeeded would still read as "not wired" and prompt again.
|
|
13522
|
-
pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
13523
|
-
}
|
|
13524
|
-
else {
|
|
13525
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
|
|
13526
|
-
}
|
|
13527
|
-
}
|
|
13528
|
-
if (projectTraps.length > 0) {
|
|
13529
|
-
// wirePi edits the USER-scope settings only — a project-scope
|
|
13530
|
-
// trap gets the exact manual fix, never a --fix that claims a
|
|
13531
|
-
// file it does not touch.
|
|
13532
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
|
|
13533
|
-
}
|
|
13534
|
-
if (!pi.wired) {
|
|
13535
|
-
console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
|
|
13536
|
-
if (autoFix) {
|
|
13537
|
-
await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
|
|
13538
|
-
}
|
|
13539
|
-
else {
|
|
13540
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages")`)} — or: pi install npm:${PI_FLAIR_PACKAGE}`);
|
|
13541
|
-
}
|
|
13542
|
-
issues++;
|
|
13543
|
-
continue;
|
|
13544
|
-
}
|
|
13545
|
-
if (pi.wiredVia === "packages") {
|
|
13546
|
-
console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
|
|
13547
|
-
if (!pi.pinnedVersion) {
|
|
13548
|
-
console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
|
|
13549
|
-
}
|
|
13550
|
-
}
|
|
13551
|
-
else {
|
|
13552
|
-
// extension-path: the documented pre-0.49 workaround (a local
|
|
13553
|
-
// path to the installed dist/index.js). Works, but the canonical
|
|
13554
|
-
// form is a "packages" entry — and a DANGLING path is a broken
|
|
13555
|
-
// wiring pi skips silently, so check the one thing checkable.
|
|
13556
|
-
if (pi.extensionPathExists) {
|
|
13557
|
-
console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via a file-path "extensions" entry (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
|
|
13558
|
-
console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
|
|
13559
|
-
}
|
|
13560
|
-
else {
|
|
13561
|
-
console.log(` ${render.icons.error} pi: the "extensions" entry ${pi.spec} in ${render.wrap(render.c.dim, pi.wiredIn)} points at a file that does not exist — pi silently skips missing extension paths`);
|
|
13562
|
-
if (autoFix) {
|
|
13563
|
-
await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
|
|
13564
|
-
}
|
|
13565
|
-
else {
|
|
13566
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages"; remove the dangling entry yourself)`)}`);
|
|
13567
|
-
}
|
|
13568
|
-
issues++;
|
|
13569
|
-
continue;
|
|
13570
|
-
}
|
|
13571
|
-
}
|
|
13572
|
-
// Env sanity (flair#1342 scope 3). pi settings carry no env block:
|
|
13573
|
-
// pi-flair reads FLAIR_* from the environment of whatever shell/IDE
|
|
13574
|
-
// launches pi. Doctor can only see ITS OWN environment — these
|
|
13575
|
-
// lines verify this shell, and say so, rather than pretending to
|
|
13576
|
-
// verify every pi launch. None of them counts as an issue: a clean
|
|
13577
|
-
// pi launched elsewhere can be fine while this shell is bare, and
|
|
13578
|
-
// vice versa.
|
|
13579
|
-
console.log(` ${render.wrap(render.c.dim, "pi-flair reads FLAIR_AGENT_ID / FLAIR_URL / FLAIR_KEY_PATH from the shell that launches pi — doctor sees only its own environment (this shell):")}`);
|
|
13580
|
-
const piEnvAgent = process.env.FLAIR_AGENT_ID;
|
|
13581
|
-
const piEnvUrl = process.env.FLAIR_URL;
|
|
13582
|
-
const piEnvKey = process.env.FLAIR_KEY_PATH;
|
|
13583
|
-
if (piEnvAgent) {
|
|
13584
|
-
console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
|
|
13585
|
-
}
|
|
13586
|
-
else {
|
|
13587
|
-
console.log(` ${render.icons.warn} FLAIR_AGENT_ID not set in this shell — pi-flair falls back to the cwd directory name as its agent id (identity varies by project); export FLAIR_AGENT_ID=<id> where pi is launched`);
|
|
13588
|
-
}
|
|
13589
|
-
if (piEnvUrl) {
|
|
13590
|
-
console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
|
|
13591
|
-
}
|
|
13592
|
-
else {
|
|
13593
|
-
console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
|
|
13594
|
-
}
|
|
13595
|
-
if (piEnvKey) {
|
|
13596
|
-
if (existsSync(piEnvKey)) {
|
|
13597
|
-
console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
|
|
13598
|
-
}
|
|
13599
|
-
else {
|
|
13600
|
-
console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
|
|
13601
|
-
}
|
|
13602
|
-
}
|
|
13603
|
-
else {
|
|
13604
|
-
console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
|
|
13605
|
-
}
|
|
13606
|
-
// Agent registration — checkable only when this shell exposes an
|
|
13607
|
-
// agent id at all; otherwise say what was NOT verified instead of
|
|
13608
|
-
// skipping silently.
|
|
13609
|
-
if (piEnvAgent) {
|
|
13610
|
-
const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
|
|
13611
|
-
const piReachable = await probeFlairReachable(piUrl);
|
|
13612
|
-
if (!piReachable) {
|
|
13613
|
-
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
|
|
13614
|
-
}
|
|
13615
|
-
else {
|
|
13616
|
-
const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
|
|
13617
|
-
if (piReg.state === "registered") {
|
|
13618
|
-
console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
|
|
13619
|
-
}
|
|
13620
|
-
else if (piReg.state === "not-registered") {
|
|
13621
|
-
console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
|
|
13622
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
|
|
13623
|
-
issues++;
|
|
13624
|
-
}
|
|
13625
|
-
else {
|
|
13626
|
-
const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
|
|
13627
|
-
console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
|
|
13628
|
-
}
|
|
13629
|
-
}
|
|
13630
|
-
}
|
|
13631
|
-
else {
|
|
13632
|
-
console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
|
|
13633
|
-
}
|
|
13569
|
+
// flair#989 — pi is a dead namespace: the pi (kind:
|
|
13570
|
+
// "native-extension") check is removed from doctor entirely. pi was
|
|
13571
|
+
// the last non-MCP client here, and a detected-but-unwired pi was
|
|
13572
|
+
// counted as an install failure for a namespace nobody opts into any
|
|
13573
|
+
// more. Doctor now diagnoses only MCP clients the user wired (below).
|
|
13574
|
+
if (client.kind !== "mcp")
|
|
13634
13575
|
continue;
|
|
13635
|
-
}
|
|
13636
13576
|
const block = readClientMcpBlock(client.id, homedir());
|
|
13637
13577
|
if (client.id === "claude-code" && block.agentId)
|
|
13638
13578
|
claudeCodeAgentId = block.agentId;
|
|
@@ -13641,7 +13581,12 @@ program
|
|
|
13641
13581
|
if (block.agentId)
|
|
13642
13582
|
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
13643
13583
|
if (!block.present) {
|
|
13644
|
-
|
|
13584
|
+
// flair#989: this client is DETECTED (binary/config on the box) but
|
|
13585
|
+
// was never wired to Flair — the user did not opt into it. That is
|
|
13586
|
+
// not an install FAILURE, so it renders as info, never a ✗, and is
|
|
13587
|
+
// not counted (the catalog's opt-in mcp-block check owns the count).
|
|
13588
|
+
// `--fix` still offers to wire it, on the user's y/N consent.
|
|
13589
|
+
console.log(` ${render.icons.info} ${client.label}: detected but not wired to Flair — optional (no Flair MCP server in ${render.wrap(render.c.dim, block.configPath)})`);
|
|
13645
13590
|
if (autoFix) {
|
|
13646
13591
|
if (dryRun) {
|
|
13647
13592
|
console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
|
|
@@ -13702,7 +13647,7 @@ program
|
|
|
13702
13647
|
// `--fix` already works, so don't clutter the suggestion.
|
|
13703
13648
|
const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
13704
13649
|
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
13705
|
-
console.log(` ${render.wrap(render.c.dim, "
|
|
13650
|
+
console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
|
|
13706
13651
|
}
|
|
13707
13652
|
continue;
|
|
13708
13653
|
}
|
|
@@ -13739,10 +13684,25 @@ program
|
|
|
13739
13684
|
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
13740
13685
|
}
|
|
13741
13686
|
}
|
|
13687
|
+
// flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
|
|
13688
|
+
// hook, continuity, Codex hook) run only for a harness the user actually
|
|
13689
|
+
// WIRED — its MCP block is present. A harness merely DETECTED on the box
|
|
13690
|
+
// but never opted into owes none of these; flagging them was the false-
|
|
13691
|
+
// positive this fix removes. Read the block fresh so a `--fix` that just
|
|
13692
|
+
// wired the client during the loop above is reflected here.
|
|
13693
|
+
const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
|
|
13694
|
+
const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
|
|
13695
|
+
const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
|
|
13742
13696
|
// Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
|
|
13743
13697
|
// Codex has a SessionStart hook too (checked below); CLAUDE.md and
|
|
13744
13698
|
// continuity stay Claude Code only.
|
|
13745
|
-
|
|
13699
|
+
//
|
|
13700
|
+
// flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
|
|
13701
|
+
// they apply, and can only fail, once Claude Code is WIRED — so they are
|
|
13702
|
+
// gated on `claudeCodeConfigured`. Continuity (below) is a separate
|
|
13703
|
+
// opt-in that renders "not enabled" as info and never a failure, so it
|
|
13704
|
+
// stays gated on mere detection (flair#1324/#1257).
|
|
13705
|
+
if (claudeCodeConfigured) {
|
|
13746
13706
|
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
13747
13707
|
if (claudeMd.present) {
|
|
13748
13708
|
console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
|
|
@@ -13816,6 +13776,30 @@ program
|
|
|
13816
13776
|
else {
|
|
13817
13777
|
console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
|
|
13818
13778
|
}
|
|
13779
|
+
// flair#1516: a hook can be wired AND still run yet be pinned to a
|
|
13780
|
+
// DIFFERENT @tpsdev-ai/flair-mcp version than the Claude Code MCP
|
|
13781
|
+
// client — an upgrade refreshed the client block but (pre-#1516)
|
|
13782
|
+
// left the hook behind, so every session silently launched the OLD
|
|
13783
|
+
// adapter. "and still runs" never caught this; compare the two pins.
|
|
13784
|
+
const claudeHookSkew = checkSessionStartHookPinSkew(homedir(), "claude-code");
|
|
13785
|
+
if (claudeHookSkew.skewed) {
|
|
13786
|
+
console.log(` ${render.icons.warn} SessionStart hook: pinned to flair-mcp@${claudeHookSkew.hookPin} but the Claude Code MCP client is pinned to @${claudeHookSkew.clientPin} — the hook still launches the OLD adapter on every session`);
|
|
13787
|
+
if (autoFix) {
|
|
13788
|
+
if (dryRun) {
|
|
13789
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
13790
|
+
}
|
|
13791
|
+
else {
|
|
13792
|
+
const repin = repinSessionStartHook(homedir(), "claude-code");
|
|
13793
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
13794
|
+
if (repin.ok && repin.action === "update")
|
|
13795
|
+
fixed++;
|
|
13796
|
+
}
|
|
13797
|
+
}
|
|
13798
|
+
else {
|
|
13799
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
|
|
13800
|
+
}
|
|
13801
|
+
issues++;
|
|
13802
|
+
}
|
|
13819
13803
|
// Independent of whether it runs today: would it stay quiet if it
|
|
13820
13804
|
// stopped? Only offered as a repair when the command is the exact
|
|
13821
13805
|
// string Flair itself wrote — a hand-edited or pinned hook is the
|
|
@@ -13869,6 +13853,10 @@ program
|
|
|
13869
13853
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
13870
13854
|
}
|
|
13871
13855
|
}
|
|
13856
|
+
} // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
|
|
13857
|
+
// Continuity capture is a standalone Claude Code opt-in — shown whenever
|
|
13858
|
+
// Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
|
|
13859
|
+
if (claudeCodeDetected) {
|
|
13872
13860
|
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
13873
13861
|
// the SessionStart check above: installed / absent / stale-form).
|
|
13874
13862
|
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
@@ -13926,7 +13914,7 @@ program
|
|
|
13926
13914
|
// command Claude Code uses, written to ~/.codex/hooks.json. Continuity
|
|
13927
13915
|
// and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
|
|
13928
13916
|
// is the hook file.
|
|
13929
|
-
if (
|
|
13917
|
+
if (codexConfigured) {
|
|
13930
13918
|
const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
|
|
13931
13919
|
if (hook.present) {
|
|
13932
13920
|
if (hook.execution === "broken") {
|
|
@@ -13952,6 +13940,28 @@ program
|
|
|
13952
13940
|
else {
|
|
13953
13941
|
console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
|
|
13954
13942
|
}
|
|
13943
|
+
// flair#1516: same version-skew check as Claude Code — a Codex hook
|
|
13944
|
+
// left behind by an upgrade keeps launching the OLD adapter while
|
|
13945
|
+
// the Codex MCP block advertises the new pin.
|
|
13946
|
+
const codexHookSkew = checkSessionStartHookPinSkew(homedir(), "codex");
|
|
13947
|
+
if (codexHookSkew.skewed) {
|
|
13948
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): pinned to flair-mcp@${codexHookSkew.hookPin} but the Codex MCP client is pinned to @${codexHookSkew.clientPin} — the hook still launches the OLD adapter on every session`);
|
|
13949
|
+
if (autoFix) {
|
|
13950
|
+
if (dryRun) {
|
|
13951
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
13952
|
+
}
|
|
13953
|
+
else {
|
|
13954
|
+
const repin = repinSessionStartHook(homedir(), "codex");
|
|
13955
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
13956
|
+
if (repin.ok && repin.action === "update")
|
|
13957
|
+
fixed++;
|
|
13958
|
+
}
|
|
13959
|
+
}
|
|
13960
|
+
else {
|
|
13961
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
|
|
13962
|
+
}
|
|
13963
|
+
issues++;
|
|
13964
|
+
}
|
|
13955
13965
|
if (!hook.silenced && hook.ours) {
|
|
13956
13966
|
console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
13957
13967
|
if (hook.upgradable) {
|
|
@@ -14484,11 +14494,13 @@ export const QUALITY_RECALL_K = 5;
|
|
|
14484
14494
|
* pulls embedding vectors inline — the defect in flair#1360 was an
|
|
14485
14495
|
* unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
|
|
14486
14496
|
* vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
|
|
14487
|
-
* 10 memories. `
|
|
14488
|
-
*
|
|
14489
|
-
*
|
|
14497
|
+
* 10 memories. `archived` is projected so the planner can drop basemented
|
|
14498
|
+
* rows before sampling (flair#857 — SemanticSearch excludes them, so an
|
|
14499
|
+
* archived row in the sample is a guaranteed miss). `type` is intentionally
|
|
14500
|
+
* omitted: it is not a declared Memory column (see schemas/memory.graphql);
|
|
14501
|
+
* snapshot exclusion keys off `subject` (`quality-snapshot/…`).
|
|
14490
14502
|
*/
|
|
14491
|
-
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
|
|
14503
|
+
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt", "archived"];
|
|
14492
14504
|
/**
|
|
14493
14505
|
* Extra most-recent rows fetched beyond `sampleSize` so
|
|
14494
14506
|
* `planRecallSpotCheck` can drop the sweep's own quality-snapshot
|
|
@@ -14635,20 +14647,26 @@ function isQualitySnapshotRow(m) {
|
|
|
14635
14647
|
* fail-closed health rules are testable without any I/O (flair#967).
|
|
14636
14648
|
*
|
|
14637
14649
|
* Order of operations, and why:
|
|
14638
|
-
* 1. drop
|
|
14650
|
+
* 1. drop archived rows (SemanticSearch excludes them — flair#857 — so a
|
|
14651
|
+
* basemented row in the sample is a guaranteed miss, not a recall signal);
|
|
14652
|
+
* 2. drop the tool's own quality-snapshot rows (never grade your own
|
|
14639
14653
|
* bookkeeping);
|
|
14640
|
-
*
|
|
14654
|
+
* 3. take the `sampleSize` most-recently-written remaining rows (unchanged —
|
|
14641
14655
|
* recency is still the sampling frame; see the issue's direction 3 for the
|
|
14642
14656
|
* stratified-sampling follow-up this deliberately does NOT take on);
|
|
14643
|
-
*
|
|
14644
|
-
*
|
|
14657
|
+
* 4. derive each cue via deriveRecallCue;
|
|
14658
|
+
* 5. judge the window: any duplicate cue, or any empty cue, makes it
|
|
14645
14659
|
* UNSCORABLE — reported as unhealthy, never silently scored.
|
|
14646
14660
|
*/
|
|
14647
14661
|
export function planRecallSpotCheck(memories, opts = {}) {
|
|
14648
14662
|
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
14649
14663
|
const rows = Array.isArray(memories) ? memories : [];
|
|
14650
|
-
|
|
14651
|
-
|
|
14664
|
+
// `archived !== true` matches SemanticSearch / AdminMemory: unset and
|
|
14665
|
+
// false stay in the live pool; only an explicit basement is dropped.
|
|
14666
|
+
const live = rows.filter((m) => m?.archived !== true);
|
|
14667
|
+
const scorable = live.filter((m) => !isQualitySnapshotRow(m ?? {}));
|
|
14668
|
+
const excludedArchivedRows = rows.length - live.length;
|
|
14669
|
+
const excludedSnapshotRows = live.length - scorable.length;
|
|
14652
14670
|
const sorted = scorable.slice().sort((a, b) => {
|
|
14653
14671
|
const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
14654
14672
|
const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
@@ -14666,7 +14684,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
|
|
|
14666
14684
|
}
|
|
14667
14685
|
const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
|
|
14668
14686
|
if (duplicateCues.length === 0 && emptyCueCount === 0) {
|
|
14669
|
-
return { sampled, health: { healthy: true }, excludedSnapshotRows };
|
|
14687
|
+
return { sampled, health: { healthy: true }, excludedSnapshotRows, excludedArchivedRows };
|
|
14670
14688
|
}
|
|
14671
14689
|
const parts = [];
|
|
14672
14690
|
if (duplicateCues.length > 0) {
|
|
@@ -14686,6 +14704,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
|
|
|
14686
14704
|
emptyCueCount,
|
|
14687
14705
|
},
|
|
14688
14706
|
excludedSnapshotRows,
|
|
14707
|
+
excludedArchivedRows,
|
|
14689
14708
|
};
|
|
14690
14709
|
}
|
|
14691
14710
|
/**
|
|
@@ -14910,13 +14929,21 @@ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
14910
14929
|
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
14911
14930
|
}
|
|
14912
14931
|
// Deterministic sample + cue derivation + fail-closed health judgment, all
|
|
14913
|
-
// pure (planRecallSpotCheck above).
|
|
14914
|
-
// "enough memories" check has to run on the
|
|
14915
|
-
// row count — an instance whose recent writes
|
|
14916
|
-
//
|
|
14932
|
+
// pure (planRecallSpotCheck above). Archived rows (flair#857) and snapshot
|
|
14933
|
+
// rows are excluded there, so the "enough memories" check has to run on the
|
|
14934
|
+
// PLANNED window, not on the raw row count — an instance whose recent writes
|
|
14935
|
+
// are mostly basemented or the sweep's own bookkeeping should skip with a
|
|
14936
|
+
// reason, not score a short window.
|
|
14917
14937
|
const plan = planRecallSpotCheck(all, { sampleSize });
|
|
14918
14938
|
if (plan.sampled.length < sampleSize) {
|
|
14919
|
-
const
|
|
14939
|
+
const exclusionParts = [];
|
|
14940
|
+
if (plan.excludedArchivedRows > 0) {
|
|
14941
|
+
exclusionParts.push(`${plan.excludedArchivedRows} archived row(s) excluded — SemanticSearch cannot return basemented memories; restore with \`flair memory restore <id>\` if they should be live`);
|
|
14942
|
+
}
|
|
14943
|
+
if (plan.excludedSnapshotRows > 0) {
|
|
14944
|
+
exclusionParts.push(`${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping`);
|
|
14945
|
+
}
|
|
14946
|
+
const excluded = exclusionParts.length > 0 ? ` (${exclusionParts.join("; ")})` : "";
|
|
14920
14947
|
return {
|
|
14921
14948
|
ok: false,
|
|
14922
14949
|
agentId,
|
|
@@ -15198,7 +15225,7 @@ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, op
|
|
|
15198
15225
|
* `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
|
|
15199
15226
|
* Throws on a write failure — the CLI action below is responsible for
|
|
15200
15227
|
* surfacing that as a clear error, same as every other write path here. */
|
|
15201
|
-
async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
15228
|
+
async function storeQualitySnapshot(agentId, agentIdSource, baseUrl, subject, snapshot) {
|
|
15202
15229
|
const memId = `${agentId}-quality-snapshot-${Date.now()}`;
|
|
15203
15230
|
const body = {
|
|
15204
15231
|
id: memId,
|
|
@@ -15210,7 +15237,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
|
15210
15237
|
type: "quality-snapshot",
|
|
15211
15238
|
createdAt: new Date().toISOString(),
|
|
15212
15239
|
};
|
|
15213
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
|
|
15240
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId, agentIdSource });
|
|
15214
15241
|
if (out?.error)
|
|
15215
15242
|
throw new Error(String(out.error));
|
|
15216
15243
|
return memId;
|
|
@@ -15226,8 +15253,8 @@ program
|
|
|
15226
15253
|
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
15227
15254
|
.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")
|
|
15228
15255
|
.action(async (opts) => {
|
|
15229
|
-
const agentId = resolveSigningAgentId(opts, "quality");
|
|
15230
|
-
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
|
|
15256
|
+
const { agentId, source } = resolveSigningAgentId(opts, "quality");
|
|
15257
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId, source);
|
|
15231
15258
|
if (opts.emit && !agentId) {
|
|
15232
15259
|
console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
|
|
15233
15260
|
process.exit(1);
|
|
@@ -15270,7 +15297,7 @@ program
|
|
|
15270
15297
|
}
|
|
15271
15298
|
}
|
|
15272
15299
|
try {
|
|
15273
|
-
emitResult.snapshotId = await storeQualitySnapshot(agentId, baseUrl, subject, current);
|
|
15300
|
+
emitResult.snapshotId = await storeQualitySnapshot(agentId, source, baseUrl, subject, current);
|
|
15274
15301
|
}
|
|
15275
15302
|
catch (err) {
|
|
15276
15303
|
emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
|
|
@@ -15651,7 +15678,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
|
|
|
15651
15678
|
process.exit(1);
|
|
15652
15679
|
}
|
|
15653
15680
|
applyAdminPassFile(opts);
|
|
15654
|
-
const agentId = resolveSigningAgentId(opts, "memory add");
|
|
15681
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory add");
|
|
15655
15682
|
if (!agentId) {
|
|
15656
15683
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15657
15684
|
process.exit(2);
|
|
@@ -15693,6 +15720,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
|
|
|
15693
15720
|
}
|
|
15694
15721
|
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15695
15722
|
agentId,
|
|
15723
|
+
agentIdSource: source,
|
|
15696
15724
|
explicitAdminPass: opts.adminPass,
|
|
15697
15725
|
adminUser: opts.adminUser,
|
|
15698
15726
|
});
|
|
@@ -15750,7 +15778,7 @@ memory.command("write-task-summary")
|
|
|
15750
15778
|
lines.push(opts.summary);
|
|
15751
15779
|
}
|
|
15752
15780
|
const content = lines.join("\n");
|
|
15753
|
-
const agentId = resolveSigningAgentId(opts, "memory write-task-summary")
|
|
15781
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory write-task-summary");
|
|
15754
15782
|
const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
|
|
15755
15783
|
const body = {
|
|
15756
15784
|
id: memId,
|
|
@@ -15767,7 +15795,7 @@ memory.command("write-task-summary")
|
|
|
15767
15795
|
if (opts.derivedFrom) {
|
|
15768
15796
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
15769
15797
|
}
|
|
15770
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
|
|
15798
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId, agentIdSource: source });
|
|
15771
15799
|
if (out?.error) {
|
|
15772
15800
|
console.error(`Error writing task summary: ${out.error}`);
|
|
15773
15801
|
process.exit(1);
|
|
@@ -15779,6 +15807,7 @@ memory.command("write-task-summary")
|
|
|
15779
15807
|
memory.command("search [query]")
|
|
15780
15808
|
.description("Semantic search over an agent's memories (query via positional arg or --q)")
|
|
15781
15809
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
15810
|
+
.option("--admin-pass <pass>", "Admin password — sign as admin while --agent names whose memories to search (flair#1500: a flag-pinned agent with no key no longer falls back to FLAIR_ADMIN_PASS)")
|
|
15782
15811
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
15783
15812
|
.option("--limit <n>", "Max results", "5")
|
|
15784
15813
|
.option("--tag <tag>")
|
|
@@ -15787,7 +15816,7 @@ memory.command("search [query]")
|
|
|
15787
15816
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15788
15817
|
.option("--port <port>", "Harper HTTP port")
|
|
15789
15818
|
.action(async (queryArg, opts) => {
|
|
15790
|
-
const agentId = resolveSigningAgentId(opts, "memory search");
|
|
15819
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory search");
|
|
15791
15820
|
if (!agentId) {
|
|
15792
15821
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15793
15822
|
process.exit(2);
|
|
@@ -15803,7 +15832,7 @@ memory.command("search [query]")
|
|
|
15803
15832
|
if (opts.includeArchived)
|
|
15804
15833
|
body.includeArchived = true;
|
|
15805
15834
|
const baseUrl = resolveBaseUrl(opts);
|
|
15806
|
-
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
15835
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId, agentIdSource: source, explicitAdminPass: opts.adminPass });
|
|
15807
15836
|
console.log(JSON.stringify(res, null, 2));
|
|
15808
15837
|
});
|
|
15809
15838
|
// ─── flair memory basement / restore ────────────────────────────────────────
|
|
@@ -15820,13 +15849,13 @@ memory.command("basement <id>")
|
|
|
15820
15849
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15821
15850
|
.option("--port <port>", "Harper HTTP port")
|
|
15822
15851
|
.action(async (id, opts) => {
|
|
15823
|
-
const agentId = resolveSigningAgentId(opts, "memory basement");
|
|
15852
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory basement");
|
|
15824
15853
|
if (!agentId) {
|
|
15825
15854
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15826
15855
|
process.exit(2);
|
|
15827
15856
|
}
|
|
15828
15857
|
const baseUrl = resolveBaseUrl(opts);
|
|
15829
|
-
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
|
|
15858
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId, agentIdSource: source });
|
|
15830
15859
|
console.log(JSON.stringify(res, null, 2));
|
|
15831
15860
|
});
|
|
15832
15861
|
memory.command("restore <id>")
|
|
@@ -15836,13 +15865,13 @@ memory.command("restore <id>")
|
|
|
15836
15865
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15837
15866
|
.option("--port <port>", "Harper HTTP port")
|
|
15838
15867
|
.action(async (id, opts) => {
|
|
15839
|
-
const agentId = resolveSigningAgentId(opts, "memory restore");
|
|
15868
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory restore");
|
|
15840
15869
|
if (!agentId) {
|
|
15841
15870
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15842
15871
|
process.exit(2);
|
|
15843
15872
|
}
|
|
15844
15873
|
const baseUrl = resolveBaseUrl(opts);
|
|
15845
|
-
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
|
|
15874
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId, agentIdSource: source });
|
|
15846
15875
|
console.log(JSON.stringify(res, null, 2));
|
|
15847
15876
|
});
|
|
15848
15877
|
memory.command("list")
|
|
@@ -15853,13 +15882,13 @@ memory.command("list")
|
|
|
15853
15882
|
.option("--limit <n>", "Max rows when using --hash-fallback", "50")
|
|
15854
15883
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
15855
15884
|
.action(async (opts) => {
|
|
15856
|
-
const agentId = resolveSigningAgentId(opts, "memory list");
|
|
15885
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory list");
|
|
15857
15886
|
if (!agentId) {
|
|
15858
15887
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
15859
15888
|
process.exit(2);
|
|
15860
15889
|
}
|
|
15861
15890
|
const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
|
|
15862
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
|
|
15891
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { agentId, agentIdSource: source });
|
|
15863
15892
|
const mode = render.resolveOutputMode(opts);
|
|
15864
15893
|
// hashFallback flag changes the lens: instead of all memories, show
|
|
15865
15894
|
// only those that need re-embedding. Keep that surface separate.
|
|
@@ -16191,7 +16220,7 @@ program
|
|
|
16191
16220
|
.option("--json", "Output raw JSON array")
|
|
16192
16221
|
.action(async (query, opts) => {
|
|
16193
16222
|
try {
|
|
16194
|
-
const agentId = resolveSigningAgentId(opts, "search");
|
|
16223
|
+
const { agentId } = resolveSigningAgentId(opts, "search");
|
|
16195
16224
|
if (!agentId) {
|
|
16196
16225
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
16197
16226
|
process.exit(2);
|
|
@@ -16351,7 +16380,7 @@ program
|
|
|
16351
16380
|
.option("--key <path>", "Ed25519 private key path")
|
|
16352
16381
|
.option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
|
|
16353
16382
|
.action(async (opts) => {
|
|
16354
|
-
const agentId = resolveSigningAgentId(opts, "bootstrap");
|
|
16383
|
+
const { agentId, source } = resolveSigningAgentId(opts, "bootstrap");
|
|
16355
16384
|
if (!agentId) {
|
|
16356
16385
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
16357
16386
|
process.exit(2);
|
|
@@ -16366,7 +16395,7 @@ program
|
|
|
16366
16395
|
// finds a usable key. Previously this only ever tried Ed25519 (no
|
|
16367
16396
|
// admin fallback at all) and sent NO Authorization header when no key
|
|
16368
16397
|
// was found, relying on Harper's local passthrough.
|
|
16369
|
-
const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, explicitKeyPath: opts.key }));
|
|
16398
|
+
const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, agentIdSource: source, explicitKeyPath: opts.key }));
|
|
16370
16399
|
if (mode === "json") {
|
|
16371
16400
|
// Agent-first: emit the full server response, augmented with the cap
|
|
16372
16401
|
// that was requested. Includes context, sections, tokenEstimate, etc.
|
|
@@ -16446,7 +16475,7 @@ relationship.command("add")
|
|
|
16446
16475
|
.option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
|
|
16447
16476
|
.option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
|
|
16448
16477
|
.action(async (opts) => {
|
|
16449
|
-
const agentId = resolveSigningAgentId(opts, "relationship add")
|
|
16478
|
+
const { agentId, source } = resolveSigningAgentId(opts, "relationship add");
|
|
16450
16479
|
const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
|
|
16451
16480
|
const body = {
|
|
16452
16481
|
id,
|
|
@@ -16463,11 +16492,11 @@ relationship.command("add")
|
|
|
16463
16492
|
body.validTo = opts.validTo;
|
|
16464
16493
|
if (opts.source)
|
|
16465
16494
|
body.source = opts.source;
|
|
16466
|
-
const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
|
|
16495
|
+
const out = await api("PUT", `/Relationship/${id}`, body, { agentId, agentIdSource: source });
|
|
16467
16496
|
console.log(JSON.stringify(out, null, 2));
|
|
16468
16497
|
});
|
|
16469
16498
|
const soul = program.command("soul").description("Manage agent soul entries");
|
|
16470
|
-
soul.command("set")
|
|
16499
|
+
addSharedCredentialOptions(soul.command("set"))
|
|
16471
16500
|
.description("Set (upsert) a soul entry for an agent by key")
|
|
16472
16501
|
.requiredOption("--agent <id>")
|
|
16473
16502
|
.requiredOption("--key <key>")
|
|
@@ -16475,6 +16504,7 @@ soul.command("set")
|
|
|
16475
16504
|
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
|
|
16476
16505
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
16477
16506
|
.action(async (opts) => {
|
|
16507
|
+
applyAdminPassFile(opts);
|
|
16478
16508
|
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
16479
16509
|
// The Soul table resource has no POST handler, so a collection POST /Soul
|
|
16480
16510
|
// 405s; the record must be written by its primary key. (#498)
|
|
@@ -16484,7 +16514,7 @@ soul.command("set")
|
|
|
16484
16514
|
// precedence — but before this, api() re-derived the signer as
|
|
16485
16515
|
// FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
|
|
16486
16516
|
// wrote a record owned by X while signing as Y (the soul family's stale rung).
|
|
16487
|
-
const agentId = resolveSigningAgentId(opts, "soul set")
|
|
16517
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul set");
|
|
16488
16518
|
const id = `${opts.agent}:${opts.key}`;
|
|
16489
16519
|
const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
|
|
16490
16520
|
id,
|
|
@@ -16493,7 +16523,7 @@ soul.command("set")
|
|
|
16493
16523
|
value: opts.value,
|
|
16494
16524
|
durability: opts.durability,
|
|
16495
16525
|
createdAt: new Date().toISOString(),
|
|
16496
|
-
}, { agentId });
|
|
16526
|
+
}, { agentId, agentIdSource: source, explicitAdminPass: opts.adminPass, adminUser: opts.adminUser });
|
|
16497
16527
|
const mode = render.resolveOutputMode(opts);
|
|
16498
16528
|
if (mode === "json") {
|
|
16499
16529
|
console.log(render.asJSON(out));
|
|
@@ -16515,8 +16545,8 @@ soul.command("get")
|
|
|
16515
16545
|
// flair#1183: /Soul reads are verified (any registered agent). Resolve the
|
|
16516
16546
|
// signer through the canonical seam so soul get honors the SAME precedence
|
|
16517
16547
|
// as every other family; a null result lets api() fall to admin-pass/floor.
|
|
16518
|
-
const agentId = resolveSigningAgentId(opts, "soul get");
|
|
16519
|
-
const out = await api("GET", `/Soul/${id}`, undefined, { agentId });
|
|
16548
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul get");
|
|
16549
|
+
const out = await api("GET", `/Soul/${id}`, undefined, { agentId, agentIdSource: source });
|
|
16520
16550
|
const mode = render.resolveOutputMode(opts);
|
|
16521
16551
|
if (mode === "json") {
|
|
16522
16552
|
console.log(render.asJSON(out));
|
|
@@ -16548,12 +16578,12 @@ soul.command("list")
|
|
|
16548
16578
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
16549
16579
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
16550
16580
|
.action(async (opts) => {
|
|
16551
|
-
const agentId = resolveSigningAgentId(opts, "soul list");
|
|
16581
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul list");
|
|
16552
16582
|
if (!agentId) {
|
|
16553
16583
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
16554
16584
|
process.exit(2);
|
|
16555
16585
|
}
|
|
16556
|
-
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
|
|
16586
|
+
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId, agentIdSource: source });
|
|
16557
16587
|
const mode = render.resolveOutputMode(opts);
|
|
16558
16588
|
if (mode === "json") {
|
|
16559
16589
|
console.log(render.asJSON(out));
|
|
@@ -17379,19 +17409,7 @@ program
|
|
|
17379
17409
|
console.warn(` warn: agent ${agent.id}: ${err.message}`);
|
|
17380
17410
|
}
|
|
17381
17411
|
}
|
|
17382
|
-
// Restore memories
|
|
17383
|
-
console.log("Restoring memories...");
|
|
17384
|
-
let memoryCount = 0;
|
|
17385
|
-
for (const memory of memories) {
|
|
17386
|
-
try {
|
|
17387
|
-
await adminPut(`/Memory/${memory.id}`, memory);
|
|
17388
|
-
memoryCount++;
|
|
17389
|
-
}
|
|
17390
|
-
catch (err) {
|
|
17391
|
-
console.warn(` warn: memory ${memory.id}: ${err.message}`);
|
|
17392
|
-
}
|
|
17393
|
-
}
|
|
17394
|
-
// Restore souls
|
|
17412
|
+
// Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
|
|
17395
17413
|
console.log("Restoring souls...");
|
|
17396
17414
|
let soulCount = 0;
|
|
17397
17415
|
for (const soul of souls) {
|
|
@@ -17403,6 +17421,17 @@ program
|
|
|
17403
17421
|
console.warn(` warn: soul ${soul.id}: ${err.message}`);
|
|
17404
17422
|
}
|
|
17405
17423
|
}
|
|
17424
|
+
console.log("Restoring memories...");
|
|
17425
|
+
let memoryCount = 0;
|
|
17426
|
+
for (const memory of memories) {
|
|
17427
|
+
try {
|
|
17428
|
+
await adminPut(`/Memory/${memory.id}`, memory);
|
|
17429
|
+
memoryCount++;
|
|
17430
|
+
}
|
|
17431
|
+
catch (err) {
|
|
17432
|
+
console.warn(` warn: memory ${memory.id}: ${err.message}`);
|
|
17433
|
+
}
|
|
17434
|
+
}
|
|
17406
17435
|
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
|
|
17407
17436
|
console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
|
|
17408
17437
|
console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
|
|
@@ -17574,30 +17603,29 @@ program
|
|
|
17574
17603
|
console.log(typeof seedOpsTarget === "string"
|
|
17575
17604
|
? ` Agent registered (ops: ${seedOpsTarget})`
|
|
17576
17605
|
: ` Agent registered`);
|
|
17577
|
-
// Restore memories
|
|
17606
|
+
// Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
|
|
17578
17607
|
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
17579
|
-
let
|
|
17580
|
-
for (const
|
|
17608
|
+
let soulCount = 0;
|
|
17609
|
+
for (const soul of data.souls ?? []) {
|
|
17581
17610
|
try {
|
|
17582
|
-
await fetch(`${baseUrl}/
|
|
17611
|
+
await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
|
|
17583
17612
|
method: "PUT",
|
|
17584
17613
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
17585
|
-
body: JSON.stringify(
|
|
17614
|
+
body: JSON.stringify(soul),
|
|
17586
17615
|
});
|
|
17587
|
-
|
|
17616
|
+
soulCount++;
|
|
17588
17617
|
}
|
|
17589
17618
|
catch { /* skip failures */ }
|
|
17590
17619
|
}
|
|
17591
|
-
|
|
17592
|
-
|
|
17593
|
-
for (const soul of data.souls ?? []) {
|
|
17620
|
+
let memCount = 0;
|
|
17621
|
+
for (const mem of data.memories ?? []) {
|
|
17594
17622
|
try {
|
|
17595
|
-
await fetch(`${baseUrl}/
|
|
17623
|
+
await fetch(`${baseUrl}/Memory/${mem.id}`, {
|
|
17596
17624
|
method: "PUT",
|
|
17597
17625
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
17598
|
-
body: JSON.stringify(
|
|
17626
|
+
body: JSON.stringify(mem),
|
|
17599
17627
|
});
|
|
17600
|
-
|
|
17628
|
+
memCount++;
|
|
17601
17629
|
}
|
|
17602
17630
|
catch { /* skip failures */ }
|
|
17603
17631
|
}
|
|
@@ -17914,7 +17942,7 @@ presence
|
|
|
17914
17942
|
.option("--port <port>", "Harper HTTP port")
|
|
17915
17943
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
17916
17944
|
.action(async (opts) => {
|
|
17917
|
-
const agentId = resolveSigningAgentId(opts, "presence set");
|
|
17945
|
+
const { agentId } = resolveSigningAgentId(opts, "presence set");
|
|
17918
17946
|
if (!agentId) {
|
|
17919
17947
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
17920
17948
|
process.exit(1);
|
|
@@ -18000,7 +18028,7 @@ workspace
|
|
|
18000
18028
|
.option("--port <port>", "Harper HTTP port")
|
|
18001
18029
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
18002
18030
|
.action(async (opts) => {
|
|
18003
|
-
const agentId = resolveSigningAgentId(opts, "workspace set");
|
|
18031
|
+
const { agentId } = resolveSigningAgentId(opts, "workspace set");
|
|
18004
18032
|
if (!agentId) {
|
|
18005
18033
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
18006
18034
|
process.exit(1);
|
|
@@ -18150,7 +18178,7 @@ program
|
|
|
18150
18178
|
.option("--port <port>", "Harper HTTP port")
|
|
18151
18179
|
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
18152
18180
|
.action(async (opts) => {
|
|
18153
|
-
const agentId = resolveSigningAgentId(opts, "orgevent");
|
|
18181
|
+
const { agentId } = resolveSigningAgentId(opts, "orgevent");
|
|
18154
18182
|
if (!agentId) {
|
|
18155
18183
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
18156
18184
|
process.exit(1);
|
|
@@ -18220,7 +18248,7 @@ program
|
|
|
18220
18248
|
.option("--json", "Output raw JSON")
|
|
18221
18249
|
.action(async (entity, opts) => {
|
|
18222
18250
|
try {
|
|
18223
|
-
const agentId = resolveSigningAgentId(opts, "attention");
|
|
18251
|
+
const { agentId } = resolveSigningAgentId(opts, "attention");
|
|
18224
18252
|
if (!agentId) {
|
|
18225
18253
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
18226
18254
|
process.exit(2);
|