@tpsdev-ai/flair 0.51.2 → 0.53.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 +1037 -566
- 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/launchd-repair.js +198 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- 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 +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { load as parseYaml } from "js-yaml";
|
|
|
5
5
|
import * as render from "./render.js";
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, openSync, closeSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, constants as fsConstants, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
|
-
import { join, resolve, sep, dirname } from "node:path";
|
|
8
|
+
import { join, resolve, sep, dirname, basename } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
|
|
11
11
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
@@ -20,20 +20,22 @@ 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";
|
|
33
33
|
import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
34
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
35
35
|
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
36
|
-
import {
|
|
36
|
+
import { classifyPlist, planLaunchdRepair, mapRepairThrow, decideAdoptStop, } from "./lib/launchd-repair.js";
|
|
37
|
+
import { stabilizeMqttNetworkKeyOrder } from "./lib/stabilize-mqtt-network.js";
|
|
38
|
+
import { applyUpgradeHookConsent, catalogIssueDelta, DOCTOR_CHECK_IDS, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
|
|
37
39
|
import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
|
|
38
40
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
39
41
|
// from the one the scheduler actually validates against. The module itself is
|
|
@@ -41,6 +43,8 @@ import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartT
|
|
|
41
43
|
// functions — this pulls in nothing but node builtins.
|
|
42
44
|
import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
|
|
43
45
|
import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
|
|
46
|
+
import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage, } from "./lib/upgrade-exec-path.js";
|
|
47
|
+
import { applyPlainTreeUpgrade, decidePlainTreeRollback, discardPlainTreePrevious, findSystemdUnitsForTree, formatPlainTreeBanner, formatPlainTreePlan, formatPlainTreeScopeFooter, planPlainTreeUpgrade, resolvePlainTreeListingTarget, resolvePlainTreeTarget, restartSystemdUnits, restorePlainTreePrevious, } from "./lib/upgrade-plain-tree.js";
|
|
44
48
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
45
49
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
46
50
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -75,6 +79,16 @@ function signBody(body, secretKey) {
|
|
|
75
79
|
// it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
|
|
76
80
|
// no longer informational-only. Credential.principalId is an unrelated
|
|
77
81
|
// owner field — do not grep that path when changing this one.
|
|
82
|
+
//
|
|
83
|
+
// S2 COMMENT-PIN (Kern P2-5, flair#1521): Message is principal-owning by `from`
|
|
84
|
+
// (PRINCIPAL_OWNER_FIELD in federation-classify.ts) but its rows carry NO
|
|
85
|
+
// `provenance` stamp, so principalIdFromRow returns undefined for them → a v:2
|
|
86
|
+
// Message push would omit `principalId` → the receiver's v≥2
|
|
87
|
+
// checkPrincipalEntitlement skips EVERY Message as `principal_mismatch` (a
|
|
88
|
+
// 100%-skip sync, not a migration). S1 does not push Message (receive-only —
|
|
89
|
+
// the spoke push list below is a separate hardcoded set), so this is inert
|
|
90
|
+
// today; the S2 pusher MUST stamp `principalId = row.from` for Message here
|
|
91
|
+
// (a per-table owner-aware derivation, not `provenance.verified.agentId`).
|
|
78
92
|
function principalIdFromRow(row) {
|
|
79
93
|
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
80
94
|
return undefined;
|
|
@@ -209,21 +223,45 @@ function launchdPlistPath(label, launchAgentsDir = defaultLaunchAgentsDir()) {
|
|
|
209
223
|
*/
|
|
210
224
|
export function buildLaunchdPlist(opts) {
|
|
211
225
|
const e = escapeXml;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
const passFile = opts.passFile;
|
|
227
|
+
// ProgramArguments: inline mode execs node directly; pass-file mode execs
|
|
228
|
+
// the launcher, which reads the secret from a 0600 file and then execs node
|
|
229
|
+
// itself (so launchd still tracks Harper as the job).
|
|
230
|
+
const programArguments = passFile
|
|
231
|
+
? `<array>
|
|
232
|
+
<string>${e(passFile.launcher)}</string>
|
|
233
|
+
<string>${e(passFile.adminPassFile)}</string>
|
|
234
|
+
<string>${e(opts.execPath)}</string>
|
|
235
|
+
<string>${e(opts.harperBinPath)}</string>
|
|
236
|
+
</array>`
|
|
237
|
+
: `<array>
|
|
219
238
|
<string>${e(opts.execPath)}</string>
|
|
220
239
|
<string>${e(opts.harperBinPath)}</string>
|
|
221
240
|
<string>run</string>
|
|
222
241
|
<string>.</string>
|
|
223
|
-
</array
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
</array>`;
|
|
243
|
+
// EnvironmentVariables: pass-file mode drops HDB_ADMIN_PASSWORD (the secret
|
|
244
|
+
// is read from the file by the launcher) and adds HOME + PATH, which the
|
|
245
|
+
// launcher needs under launchd's minimal env to start Harper non-interactively.
|
|
246
|
+
const environmentVariables = passFile
|
|
247
|
+
? `<dict>
|
|
248
|
+
<key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
|
|
249
|
+
<key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
|
|
250
|
+
<key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
|
|
251
|
+
<key>DEFAULTS_MODE</key><string>dev</string>
|
|
252
|
+
<key>HDB_ADMIN_USERNAME</key><string>${e(opts.adminUser)}</string>
|
|
253
|
+
<key>THREADS_COUNT</key><string>1</string>
|
|
254
|
+
<key>NODE_HOSTNAME</key><string>localhost</string>
|
|
255
|
+
<key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
|
|
256
|
+
<key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
|
|
257
|
+
<key>LOCAL_STUDIO</key><string>false</string>
|
|
258
|
+
<key>MQTT_NETWORK_PORT</key><string>null</string>
|
|
259
|
+
<key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
|
|
260
|
+
<key>MQTT_WEBSOCKET</key><string>false</string>
|
|
261
|
+
<key>HOME</key><string>${e(passFile.home)}</string>
|
|
262
|
+
<key>PATH</key><string>${e(passFile.path)}</string>
|
|
263
|
+
</dict>`
|
|
264
|
+
: `<dict>
|
|
227
265
|
<key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
|
|
228
266
|
<key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
|
|
229
267
|
<key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
|
|
@@ -235,7 +273,20 @@ export function buildLaunchdPlist(opts) {
|
|
|
235
273
|
<key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
|
|
236
274
|
<key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
|
|
237
275
|
<key>LOCAL_STUDIO</key><string>false</string>
|
|
238
|
-
|
|
276
|
+
<key>MQTT_NETWORK_PORT</key><string>null</string>
|
|
277
|
+
<key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
|
|
278
|
+
<key>MQTT_WEBSOCKET</key><string>false</string>
|
|
279
|
+
</dict>`;
|
|
280
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
281
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
282
|
+
<plist version="1.0">
|
|
283
|
+
<dict>
|
|
284
|
+
<key>Label</key><string>${e(opts.label)}</string>
|
|
285
|
+
<key>ProgramArguments</key>
|
|
286
|
+
${programArguments}
|
|
287
|
+
<key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
|
|
288
|
+
<key>EnvironmentVariables</key>
|
|
289
|
+
${environmentVariables}
|
|
239
290
|
<key>RunAtLoad</key><true/>
|
|
240
291
|
<key>KeepAlive</key><true/>
|
|
241
292
|
<key>StandardOutPath</key><string>${e(join(opts.dataDir, "log", "launchd-stdout.log"))}</string>
|
|
@@ -243,6 +294,46 @@ export function buildLaunchdPlist(opts) {
|
|
|
243
294
|
</dict>
|
|
244
295
|
</plist>`;
|
|
245
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Absolute path to the product-owned launchd launcher (flair#1573 slice a).
|
|
299
|
+
* Shipped in the package under templates/launchd/; the plist's
|
|
300
|
+
* ProgramArguments point at it in pass-file mode. `packageRoot` is injectable
|
|
301
|
+
* so tests can point this at a fixture tree instead of the real package dir.
|
|
302
|
+
*/
|
|
303
|
+
export function launchdLauncherPath(packageRoot = flairPackageDir()) {
|
|
304
|
+
return join(packageRoot, "templates", "launchd", "start-flair-with-admin-pass.sh");
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Write `content` to `path` atomically: write to a temp file in the SAME
|
|
308
|
+
* directory, then rename over the target. Same-fs rename is atomic on POSIX,
|
|
309
|
+
* so a reader never observes a half-written file. `mode` is applied to the
|
|
310
|
+
* temp file from creation — pass 0o600 when `content` holds a secret, so the
|
|
311
|
+
* secret is never briefly world-readable on disk (flair#1573 slice a).
|
|
312
|
+
*/
|
|
313
|
+
export function writeFileAtomic(path, content, mode) {
|
|
314
|
+
const dir = dirname(path);
|
|
315
|
+
mkdirSync(dir, { recursive: true });
|
|
316
|
+
const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
317
|
+
try {
|
|
318
|
+
writeFileSync(tmpPath, content, { mode });
|
|
319
|
+
renameSync(tmpPath, path);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
try {
|
|
323
|
+
unlinkSync(tmpPath);
|
|
324
|
+
}
|
|
325
|
+
catch { /* best effort */ }
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Write the admin password to `path` with mode 0600 from creation (flair#1573
|
|
331
|
+
* slice a). The secret is written to a temp file in the same dir (0600) and
|
|
332
|
+
* renamed into place, so it is never briefly world-readable.
|
|
333
|
+
*/
|
|
334
|
+
export function writeAdminPassFile(path, content) {
|
|
335
|
+
writeFileAtomic(path, content, 0o600);
|
|
336
|
+
}
|
|
246
337
|
/**
|
|
247
338
|
* Which launchd label an existing installation for `dataDir` is actually
|
|
248
339
|
* registered under right now. Prefers the new instance-scoped label if its
|
|
@@ -687,10 +778,14 @@ function resolveSigningIdentityFor(opts, command) {
|
|
|
687
778
|
emitSigningIdentityDebug(resolved, command);
|
|
688
779
|
return resolved;
|
|
689
780
|
}
|
|
690
|
-
// Same seam, returning
|
|
691
|
-
//
|
|
781
|
+
// Same seam, returning the full ResolvedSigningIdentity (agentId + source) so
|
|
782
|
+
// callers can thread the source down to api()/authedRequest. The source is what
|
|
783
|
+
// lets authedRequest distinguish a flag-pinned agent (--agent X, flair#1500)
|
|
784
|
+
// from an env-pinned one (FLAIR_AGENT_ID) — the flag must sign as itself BEFORE
|
|
785
|
+
// env admin, while the env keeps its legacy Basic behavior. Still emits the
|
|
786
|
+
// debug line via resolveSigningIdentityFor.
|
|
692
787
|
function resolveSigningAgentId(opts, command) {
|
|
693
|
-
return resolveSigningIdentityFor(opts, command)
|
|
788
|
+
return resolveSigningIdentityFor(opts, command);
|
|
694
789
|
}
|
|
695
790
|
// ── Shared credential/identity flag surface (flair#1106) ─────────────────────
|
|
696
791
|
// Sibling commands (memory add, backup, federation sync) used to drift on
|
|
@@ -838,6 +933,26 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
|
|
|
838
933
|
network: { port: opsNetworkPortValue(opsBindHost, opsPort), cors: true, domainSocket: opsSocket },
|
|
839
934
|
};
|
|
840
935
|
}
|
|
936
|
+
/**
|
|
937
|
+
* Harper config that fully disables the MQTT broker (flair#1586).
|
|
938
|
+
*
|
|
939
|
+
* Flair does not use MQTT. Harper's mqtt component (server/mqtt.ts
|
|
940
|
+
* `handleApplication`) binds a TCP listener on `mqtt.network.port` (1883) and
|
|
941
|
+
* a TLS listener on `mqtt.network.securePort` (8883) whenever EITHER is truthy
|
|
942
|
+
* (`if (port || securePort)`), plus a WebSocket upgrade path when
|
|
943
|
+
* `mqtt.webSocket` is true. Nulling only `network.port` leaves
|
|
944
|
+
* `network.securePort` at its 8883 default, so the TLS listener still binds.
|
|
945
|
+
* Fully disabling MQTT requires nulling BOTH ports and turning off the
|
|
946
|
+
* WebSocket path.
|
|
947
|
+
*
|
|
948
|
+
* Note: config-root.schema.json still documents a flat `mqtt.port` /
|
|
949
|
+
* `mqtt.securePort`, but the runtime reads the nested `mqtt.network.*` form
|
|
950
|
+
* (see static/defaultConfig.yaml) — the flat keys are stale.
|
|
951
|
+
*/
|
|
952
|
+
const MQTT_DISABLED_CONFIG = {
|
|
953
|
+
network: { port: null, securePort: null },
|
|
954
|
+
webSocket: false,
|
|
955
|
+
};
|
|
841
956
|
/**
|
|
842
957
|
* Build the flair-owned environment overrides for a DIRECT (non-launchd)
|
|
843
958
|
* Harper spawn (flair#863) — shared by `flair start`'s fallback path and
|
|
@@ -852,6 +967,15 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
|
|
|
852
967
|
* the ops API to all interfaces on every restart/upgrade, and persisted it.
|
|
853
968
|
* One builder means the next spawn site cannot reintroduce that gap.
|
|
854
969
|
*
|
|
970
|
+
* MQTT (flair#1586): the direct-spawn path re-asserts the mqtt disable via the
|
|
971
|
+
* individual MQTT_* env vars (the same channel as OPERATIONSAPI_NETWORK_PORT /
|
|
972
|
+
* HTTP_PORT) rather than HARPER_SET_CONFIG, so it cannot reintroduce the
|
|
973
|
+
* SET_CONFIG drift/restore gap. "null" casts to a null port (Harper's
|
|
974
|
+
* castConfigValue), which passes config validation (portConstraints
|
|
975
|
+
* `.empty(null)`) and is falsy, so Harper's mqtt component (server/mqtt.ts
|
|
976
|
+
* `if (port || securePort)`) binds neither the TCP (1883) nor TLS (8883)
|
|
977
|
+
* listener, and MQTT_WEBSOCKET=false turns off the WebSocket upgrade path.
|
|
978
|
+
*
|
|
855
979
|
* Deliberately omits HDB_ADMIN_PASSWORD when no password is in hand: an empty
|
|
856
980
|
* string would strip Harper's auth on an existing install.
|
|
857
981
|
*/
|
|
@@ -865,6 +989,14 @@ export function buildDirectSpawnEnv(opts) {
|
|
|
865
989
|
HDB_ADMIN_USERNAME: opts.adminUser,
|
|
866
990
|
HTTP_PORT: String(opts.httpPort),
|
|
867
991
|
OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
|
|
992
|
+
// flair#1586: fully disable the MQTT broker (Flair does not use it). "null"
|
|
993
|
+
// casts to a null port (Harper's castConfigValue), which passes config
|
|
994
|
+
// validation (portConstraints `.empty(null)`) and is falsy, so Harper binds
|
|
995
|
+
// neither the TCP (1883) nor TLS (8883) listener; MQTT_WEBSOCKET=false turns
|
|
996
|
+
// off the WebSocket upgrade path.
|
|
997
|
+
MQTT_NETWORK_PORT: "null",
|
|
998
|
+
MQTT_NETWORK_SECUREPORT: "null",
|
|
999
|
+
MQTT_WEBSOCKET: "false",
|
|
868
1000
|
LOCAL_STUDIO: "false",
|
|
869
1001
|
// flair#905 / lrf5: Harper's forceDowngradePrompt reads CONFIRM_DOWNGRADE
|
|
870
1002
|
// from the environment (via the `prompt` npm package's assignCmdEnvVariables
|
|
@@ -1484,6 +1616,7 @@ async function api(method, path, body, options) {
|
|
|
1484
1616
|
keysDir: options?.keysDir,
|
|
1485
1617
|
explicitAdminPass: options?.explicitAdminPass,
|
|
1486
1618
|
adminUser: options?.adminUser,
|
|
1619
|
+
agentIdSource: options?.agentIdSource,
|
|
1487
1620
|
});
|
|
1488
1621
|
}
|
|
1489
1622
|
/**
|
|
@@ -2413,10 +2546,30 @@ const FLAIR_AGENT_PERMISSION = {
|
|
|
2413
2546
|
Memory: grant(true, true, true, true),
|
|
2414
2547
|
MemoryCandidate: grant(true, true, true, true),
|
|
2415
2548
|
MemoryGrant: grant(true, true, true, true),
|
|
2549
|
+
// Asset (images-in-Flair slice 1). Harper authorizes BEFORE Asset.post
|
|
2550
|
+
// runs, so a de-elevated flair_agent needs the table grant or signed
|
|
2551
|
+
// POST /Asset 403s as AccessViolation (Kern P0). CRUD envelope;
|
|
2552
|
+
// owner-only + write-time size/MIME gates live in resources/Asset.ts.
|
|
2553
|
+
Asset: grant(true, true, true, true),
|
|
2416
2554
|
Soul: grant(true, true, true, false),
|
|
2417
2555
|
OrgEvent: grant(true, true, true, true),
|
|
2418
2556
|
WorkspaceState: grant(true, true, true, true),
|
|
2419
2557
|
Relationship: grant(true, true, true, true),
|
|
2558
|
+
// Flair Relay S1 (flair#1521). Harper authorizes BEFORE the resource
|
|
2559
|
+
// methods run, so a de-elevated flair_agent needs the table grant OR it
|
|
2560
|
+
// 403s on POST /Message before relaySend is reached (Kern P0-2). read =
|
|
2561
|
+
// the party-scoped collection (Message.search); insert = send via post().
|
|
2562
|
+
// update = FALSE (least privilege, Kern P0 blocker): the ack's write goes
|
|
2563
|
+
// through the IN-PROCESS static accessor (relayConsume → deps.messages.put),
|
|
2564
|
+
// which bypasses role gates entirely — the same raw-put seam Federation.ts
|
|
2565
|
+
// relies on — so update:true is NOT needed for any legitimate path. Leaving
|
|
2566
|
+
// it granted let PATCH /Message/<id> reach Table's update verb, whose
|
|
2567
|
+
// authorize step consults update:true and PASSES for any de-elevated agent,
|
|
2568
|
+
// bypassing Message.put()'s FORBIDDEN guard AND relayConsume's recipient-only
|
|
2569
|
+
// check (Message has no patch() at the platform level → TableResource.patch
|
|
2570
|
+
// runs update()+save() directly). delete = false: direct deletes are
|
|
2571
|
+
// admin/internal only. Message.patch() also guards the verb in-resource.
|
|
2572
|
+
Message: grant(true, true, false, false),
|
|
2420
2573
|
Integration: grant(true, true, true, true),
|
|
2421
2574
|
Credential: grant(true, true, true, true),
|
|
2422
2575
|
Presence: grant(true, true, true, false),
|
|
@@ -2430,6 +2583,9 @@ const FLAIR_AGENT_PERMISSION = {
|
|
|
2430
2583
|
// (create → count → delete → count again → repeat), defeating the cap
|
|
2431
2584
|
// entirely. See resources/MemoryUsage.ts's module doc.
|
|
2432
2585
|
MemoryUsage: grant(true, true, false, false),
|
|
2586
|
+
// MemoryHitStat (flair#1528): internal search-hit ledger. No agent REST
|
|
2587
|
+
// surface (@table without @export). Counts overlay onto Memory reads.
|
|
2588
|
+
MemoryHitStat: grant(false, false, false, false),
|
|
2433
2589
|
// Agent: read for discovery, update own card; creation/removal is admin.
|
|
2434
2590
|
Agent: grant(true, false, true, false),
|
|
2435
2591
|
// Read-only reference data.
|
|
@@ -3389,7 +3545,7 @@ program
|
|
|
3389
3545
|
rootPath: dataDir,
|
|
3390
3546
|
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
3391
3547
|
operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
|
|
3392
|
-
mqtt:
|
|
3548
|
+
mqtt: MQTT_DISABLED_CONFIG,
|
|
3393
3549
|
localStudio: { enabled: false },
|
|
3394
3550
|
authentication: { authorizeLocal: false, enableSessions: true },
|
|
3395
3551
|
});
|
|
@@ -3411,6 +3567,12 @@ program
|
|
|
3411
3567
|
// all interfaces. See opsNetworkPortValue's doc comment.
|
|
3412
3568
|
OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
|
|
3413
3569
|
LOCAL_STUDIO: "false",
|
|
3570
|
+
// flair#1586: same MQTT_* re-assert as buildDirectSpawnEnv / the
|
|
3571
|
+
// launchd plist, so init cannot restore Harper's 1883/8883 defaults
|
|
3572
|
+
// on a later boot that omits HARPER_SET_CONFIG.
|
|
3573
|
+
MQTT_NETWORK_PORT: "null",
|
|
3574
|
+
MQTT_NETWORK_SECUREPORT: "null",
|
|
3575
|
+
MQTT_WEBSOCKET: "false",
|
|
3414
3576
|
};
|
|
3415
3577
|
// models (flair#504 Phase 1): the embedding backend registers itself
|
|
3416
3578
|
// in-process at boot (resources/embeddings-boot.ts, loaded by
|
|
@@ -3514,7 +3676,7 @@ program
|
|
|
3514
3676
|
rootPath: dataDir,
|
|
3515
3677
|
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
3516
3678
|
operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
|
|
3517
|
-
mqtt:
|
|
3679
|
+
mqtt: MQTT_DISABLED_CONFIG,
|
|
3518
3680
|
localStudio: { enabled: false },
|
|
3519
3681
|
authentication: { authorizeLocal: false, enableSessions: true },
|
|
3520
3682
|
});
|
|
@@ -3672,7 +3834,7 @@ program
|
|
|
3672
3834
|
console.log("");
|
|
3673
3835
|
for (const [key, value] of soulEntries) {
|
|
3674
3836
|
try {
|
|
3675
|
-
await
|
|
3837
|
+
await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
|
|
3676
3838
|
console.log(` ✓ soul:${key} set`);
|
|
3677
3839
|
}
|
|
3678
3840
|
catch (err) {
|
|
@@ -4474,8 +4636,9 @@ agent
|
|
|
4474
4636
|
/** Best-effort seed-validity check for a `.key` file: does it parse via any
|
|
4475
4637
|
* of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
|
|
4476
4638
|
* — the same loader `flair mcp token` uses) accepts? Never throws — used
|
|
4477
|
-
* only to decide "
|
|
4478
|
-
* actually sign anything.
|
|
4639
|
+
* only to decide "unidentified" vs. "worth a registration check", not to
|
|
4640
|
+
* actually sign anything. An unparseable file is not junk: the keys dir is
|
|
4641
|
+
* also where FileKeyStore writes AES-256-GCM blobs (flair#1026). */
|
|
4479
4642
|
function isValidPrivateKeySeedFile(keyPath) {
|
|
4480
4643
|
try {
|
|
4481
4644
|
loadEd25519PrivateKeyFromFile(keyPath);
|
|
@@ -4490,10 +4653,11 @@ function isValidPrivateKeySeedFile(keyPath) {
|
|
|
4490
4653
|
* never writes or moves anything; see applyKeyPrune below for the actual
|
|
4491
4654
|
* move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
|
|
4492
4655
|
* 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
|
|
4656
|
+
* `.key` files with an unparseable seed are "unidentified" without a network
|
|
4657
|
+
* call either — reported, never pruned (flair#1026). Only a `.key` file that
|
|
4658
|
+
* DOES parse triggers a signed `GET /Agent/:id` against `baseUrl`
|
|
4659
|
+
* (checkAgentRegistered above, the exact same check doctor's registration
|
|
4660
|
+
* gate uses).
|
|
4497
4661
|
*
|
|
4498
4662
|
* If that check EVER reports "unreachable" — the instance couldn't be
|
|
4499
4663
|
* confirmed up for that key — the WHOLE run aborts immediately
|
|
@@ -4544,7 +4708,7 @@ export async function classifyKeysDir(keysDir, baseUrl) {
|
|
|
4544
4708
|
// flair#1023 added "key-unreadable". It cannot occur here — this key's
|
|
4545
4709
|
// seed already parsed via isValidPrivateKeySeedFile above — but is
|
|
4546
4710
|
// handled explicitly rather than folded into the else: a key that will
|
|
4547
|
-
// not load
|
|
4711
|
+
// not load is "unidentified", not prunable "invalid" (flair#1026).
|
|
4548
4712
|
const decision = reg.state === "key-unreadable"
|
|
4549
4713
|
? classifyKeyFile(c.agentId, false, null, baseUrl)
|
|
4550
4714
|
: classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
|
|
@@ -4604,10 +4768,11 @@ keys
|
|
|
4604
4768
|
}
|
|
4605
4769
|
const stale = result.entries.filter((e) => e.class === "stale");
|
|
4606
4770
|
const invalid = result.entries.filter((e) => e.class === "invalid");
|
|
4771
|
+
const unidentified = result.entries.filter((e) => e.class === "unidentified");
|
|
4607
4772
|
const kept = result.entries.filter((e) => e.class === "keep");
|
|
4608
4773
|
const ignored = result.entries.filter((e) => e.class === "ignored");
|
|
4609
4774
|
const prunable = [...stale, ...invalid];
|
|
4610
|
-
if (stale.length + invalid.length + kept.length === 0) {
|
|
4775
|
+
if (stale.length + invalid.length + unidentified.length + kept.length === 0) {
|
|
4611
4776
|
console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
|
|
4612
4777
|
console.log("");
|
|
4613
4778
|
return;
|
|
@@ -4616,12 +4781,15 @@ keys
|
|
|
4616
4781
|
const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
|
|
4617
4782
|
console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
|
|
4618
4783
|
}
|
|
4784
|
+
for (const e of unidentified) {
|
|
4785
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.bold, e.name)} — unidentified: ${e.reason}`);
|
|
4786
|
+
}
|
|
4619
4787
|
for (const e of kept) {
|
|
4620
4788
|
console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
|
|
4621
4789
|
}
|
|
4622
4790
|
if (!apply) {
|
|
4623
4791
|
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`)}`);
|
|
4792
|
+
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
4793
|
if (prunable.length > 0) {
|
|
4626
4794
|
console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
|
|
4627
4795
|
}
|
|
@@ -4633,7 +4801,7 @@ keys
|
|
|
4633
4801
|
for (const m of moved) {
|
|
4634
4802
|
console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
|
|
4635
4803
|
}
|
|
4636
|
-
console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
|
|
4804
|
+
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
4805
|
});
|
|
4638
4806
|
// ─── flair hook ──────────────────────────────────────────────────────────────
|
|
4639
4807
|
// Ambient memory via harness SessionStart hooks (flair#745, design record
|
|
@@ -7937,6 +8105,9 @@ rem
|
|
|
7937
8105
|
.command("candidates")
|
|
7938
8106
|
.description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
|
|
7939
8107
|
.option("--port <port>", "Harper HTTP port")
|
|
8108
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
8109
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
8110
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7940
8111
|
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
7941
8112
|
.option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
|
|
7942
8113
|
.option("--json", "Output as JSON for scripting")
|
|
@@ -7952,16 +8123,35 @@ rem
|
|
|
7952
8123
|
console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
|
|
7953
8124
|
process.exit(1);
|
|
7954
8125
|
}
|
|
8126
|
+
const opsPort = resolveOpsPort(opts);
|
|
8127
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8128
|
+
if (!adminPass) {
|
|
8129
|
+
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
8130
|
+
process.exit(1);
|
|
8131
|
+
}
|
|
8132
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
7955
8133
|
try {
|
|
7956
|
-
const
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
8134
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
8135
|
+
method: "POST",
|
|
8136
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
8137
|
+
body: JSON.stringify({
|
|
8138
|
+
operation: "search_by_conditions",
|
|
8139
|
+
schema: "flair",
|
|
8140
|
+
table: "MemoryCandidate",
|
|
8141
|
+
operator: "and",
|
|
8142
|
+
conditions: [
|
|
8143
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
8144
|
+
{ search_attribute: "status", search_type: "equals", search_value: status },
|
|
8145
|
+
],
|
|
8146
|
+
get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
|
|
8147
|
+
}),
|
|
7963
8148
|
});
|
|
7964
|
-
|
|
8149
|
+
if (!res.ok) {
|
|
8150
|
+
const text = await res.text().catch(() => "");
|
|
8151
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
8152
|
+
process.exit(1);
|
|
8153
|
+
}
|
|
8154
|
+
const candidates = await res.json();
|
|
7965
8155
|
const mode = render.resolveOutputMode(opts);
|
|
7966
8156
|
if (mode === "json") {
|
|
7967
8157
|
console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
|
|
@@ -8011,209 +8201,8 @@ rem
|
|
|
8011
8201
|
// Pure validators extracted for testability. The action callbacks below thread
|
|
8012
8202
|
// these through process.exit on failure; the helpers themselves are
|
|
8013
8203
|
// 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
|
-
}
|
|
8204
|
+
import { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, validateHumanReviewerId } from "./rem/promote-policy.js";
|
|
8205
|
+
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
8206
|
// ─── flair rem promote ───────────────────────────────────────────────────────
|
|
8218
8207
|
// Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
|
|
8219
8208
|
// or persistent Memory. Both --rationale and --to are required (spec § 5: no
|
|
@@ -8249,6 +8238,17 @@ rem
|
|
|
8249
8238
|
process.exit(1);
|
|
8250
8239
|
}
|
|
8251
8240
|
try {
|
|
8241
|
+
if (opts.to === "memory") {
|
|
8242
|
+
const promoted = await api("POST", "/PromoteMemoryCandidate", {
|
|
8243
|
+
candidateId, rationale: opts.rationale,
|
|
8244
|
+
...(opts.reviewer ? { reviewerId: opts.reviewer } : {}),
|
|
8245
|
+
});
|
|
8246
|
+
if (promoted?.error)
|
|
8247
|
+
throw new Error(promoted.error);
|
|
8248
|
+
console.log(`✅ Wrote Memory ${promoted.memoryId} (durability=persistent)`);
|
|
8249
|
+
console.log(`✅ Candidate ${candidateId} marked promoted → memory, reviewer=${promoted.reviewerId}`);
|
|
8250
|
+
return;
|
|
8251
|
+
}
|
|
8252
8252
|
// Fetch the candidate
|
|
8253
8253
|
const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
|
|
8254
8254
|
const candidateData = (candidate && !candidate.error) ? candidate : null;
|
|
@@ -8295,63 +8295,31 @@ rem
|
|
|
8295
8295
|
}
|
|
8296
8296
|
// Soul entries are agentId-scoped and cannot carry a per-user scope tag,
|
|
8297
8297
|
// so an ADK-sourced candidate promoted to Soul is a cross-user leak by
|
|
8298
|
-
// construction — fail closed
|
|
8299
|
-
//
|
|
8300
|
-
// foundation.)
|
|
8298
|
+
// construction — fail closed here and again on Soul.post/put so a
|
|
8299
|
+
// scripted PUT /Soul cannot bypass the CLI.
|
|
8301
8300
|
if (opts.to === "soul" && tagDecision.adkSourced) {
|
|
8302
8301
|
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
8302
|
process.exit(1);
|
|
8304
8303
|
}
|
|
8305
8304
|
const promotedTags = tagDecision.tags;
|
|
8306
8305
|
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})`);
|
|
8306
|
+
// Memory promotion is handled by the server workflow above.
|
|
8307
|
+
const soulId = `${candidate.agentId}-${opts.key}`;
|
|
8308
|
+
const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
|
|
8309
|
+
id: soulId,
|
|
8310
|
+
agentId: candidate.agentId,
|
|
8311
|
+
key: opts.key,
|
|
8312
|
+
value: candidate.claim,
|
|
8313
|
+
priority: "standard",
|
|
8314
|
+
durability: "persistent",
|
|
8315
|
+
createdAt: decidedAt,
|
|
8316
|
+
updatedAt: decidedAt,
|
|
8317
|
+
});
|
|
8318
|
+
if (soulWrite?.error) {
|
|
8319
|
+
console.error(`Error writing Soul: ${soulWrite.error}`);
|
|
8320
|
+
process.exit(1);
|
|
8354
8321
|
}
|
|
8322
|
+
console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
|
|
8355
8323
|
// Update the candidate row
|
|
8356
8324
|
const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
|
|
8357
8325
|
...candidate,
|
|
@@ -8524,6 +8492,9 @@ remNightly
|
|
|
8524
8492
|
.command("run-once")
|
|
8525
8493
|
.description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
|
|
8526
8494
|
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
8495
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
8496
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
8497
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
8527
8498
|
.option("--dry-run", "Log the row but skip the snapshot write")
|
|
8528
8499
|
.action(async (opts) => {
|
|
8529
8500
|
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
@@ -8532,12 +8503,47 @@ remNightly
|
|
|
8532
8503
|
process.exit(1);
|
|
8533
8504
|
}
|
|
8534
8505
|
const { runNightlyCycle } = await import("./rem/runner.js");
|
|
8506
|
+
// The runner is agent-authed and cannot reach the ops port itself. When
|
|
8507
|
+
// admin credentials are available, inject an ops-API `search_by_conditions`
|
|
8508
|
+
// helper so the pending-candidate count can be sampled; otherwise the count
|
|
8509
|
+
// is best-effort 0 (the cycle still runs).
|
|
8510
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8511
|
+
const opsSearch = adminPass
|
|
8512
|
+
? async (table, conditions, getAttributes) => {
|
|
8513
|
+
const opsPort = resolveOpsPort(opts);
|
|
8514
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
8515
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
8516
|
+
method: "POST",
|
|
8517
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
8518
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions, get_attributes: getAttributes }),
|
|
8519
|
+
});
|
|
8520
|
+
if (!res.ok)
|
|
8521
|
+
throw new Error(`ops API failed (${res.status})`);
|
|
8522
|
+
const raw = await res.json();
|
|
8523
|
+
return Array.isArray(raw) ? raw : (raw?.results ?? []);
|
|
8524
|
+
}
|
|
8525
|
+
: undefined;
|
|
8535
8526
|
try {
|
|
8527
|
+
const healthBase = (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`).replace(/\/+$/, "");
|
|
8536
8528
|
const result = await runNightlyCycle({
|
|
8537
8529
|
agentId,
|
|
8538
8530
|
flairVersion: __pkgVersion,
|
|
8539
8531
|
apiCall: api,
|
|
8532
|
+
opsSearch,
|
|
8540
8533
|
dryRun: !!opts.dryRun,
|
|
8534
|
+
healthProbe: async (timeoutMs) => {
|
|
8535
|
+
const t = Date.now();
|
|
8536
|
+
try {
|
|
8537
|
+
const res = await fetch(`${healthBase}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
8538
|
+
if (!res.ok) {
|
|
8539
|
+
return { ok: false, elapsedMs: Date.now() - t, error: `GET /Health returned HTTP ${res.status}` };
|
|
8540
|
+
}
|
|
8541
|
+
return { ok: true, elapsedMs: Date.now() - t };
|
|
8542
|
+
}
|
|
8543
|
+
catch (err) {
|
|
8544
|
+
return { ok: false, elapsedMs: Date.now() - t, error: err?.message ?? String(err) };
|
|
8545
|
+
}
|
|
8546
|
+
},
|
|
8541
8547
|
});
|
|
8542
8548
|
const row = result.logRow;
|
|
8543
8549
|
console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
|
|
@@ -8558,6 +8564,12 @@ remNightly
|
|
|
8558
8564
|
if (row.candidates) {
|
|
8559
8565
|
console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
|
|
8560
8566
|
}
|
|
8567
|
+
if (row.distill) {
|
|
8568
|
+
const remaining = Math.max(0, row.distill.unreflected - row.distill.gathered);
|
|
8569
|
+
console.log(`Distilled: ${row.distill.gathered} memor${row.distill.gathered === 1 ? "y" : "ies"} (cap ${row.distill.maxMemories}; ${remaining} unreflected remaining)`);
|
|
8570
|
+
if (row.distill.aborted)
|
|
8571
|
+
console.log(`Aborted: yes — in-flight distillation stopped (flair rem pause)`);
|
|
8572
|
+
}
|
|
8561
8573
|
// row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
|
|
8562
8574
|
// this cycle — i.e. a non-dry-run cycle for an ADK agentId.
|
|
8563
8575
|
if (row.autoPromoted) {
|
|
@@ -8570,6 +8582,10 @@ remNightly
|
|
|
8570
8582
|
console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
|
|
8571
8583
|
}
|
|
8572
8584
|
console.log(`Duration: ${row.durationMs}ms`);
|
|
8585
|
+
if (result.status === "refused") {
|
|
8586
|
+
console.log(`\nNote: REM refused to start because /Health could not be served.`);
|
|
8587
|
+
console.log(`Restore /Health before retrying, or \`flair rem pause\` to stop the scheduler.`);
|
|
8588
|
+
}
|
|
8573
8589
|
if (row.errors.length > 0) {
|
|
8574
8590
|
console.log(`Errors:`);
|
|
8575
8591
|
for (const e of row.errors)
|
|
@@ -8635,8 +8651,7 @@ remSnapshot
|
|
|
8635
8651
|
//
|
|
8636
8652
|
// The <date> argument is an ISO-timestamp prefix or date-only prefix; the
|
|
8637
8653
|
// command picks the latest snapshot matching that prefix.
|
|
8638
|
-
rem
|
|
8639
|
-
.command("restore <date>")
|
|
8654
|
+
addSharedCredentialOptions(rem.command("restore <date>"))
|
|
8640
8655
|
.description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
|
|
8641
8656
|
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
8642
8657
|
.option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
|
|
@@ -8674,12 +8689,24 @@ rem
|
|
|
8674
8689
|
// --apply path: live replay via src/rem/restore.ts
|
|
8675
8690
|
if (opts.apply) {
|
|
8676
8691
|
const { applySnapshot } = await import("./rem/restore.js");
|
|
8692
|
+
applyAdminPassFile(opts);
|
|
8693
|
+
const restoreBase = process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`;
|
|
8694
|
+
const adminPass = opts.dryRun ? undefined : resolveLocalAdminPass(opts.adminPass, !isLocalBase(restoreBase));
|
|
8695
|
+
if (!opts.dryRun && !adminPass) {
|
|
8696
|
+
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for rem restore --apply " +
|
|
8697
|
+
"(Soul rewrite is operator-only; an agent key is refused).");
|
|
8698
|
+
process.exit(1);
|
|
8699
|
+
}
|
|
8700
|
+
const soulApiCall = adminPass
|
|
8701
|
+
? (method, path, body) => api(method, path, body, { explicitAdminPass: adminPass, adminUser: opts.adminUser, agentId: null })
|
|
8702
|
+
: undefined;
|
|
8677
8703
|
try {
|
|
8678
8704
|
const result = await applySnapshot({
|
|
8679
8705
|
agentId,
|
|
8680
8706
|
snapshotPath: match.path,
|
|
8681
8707
|
flairVersion: __pkgVersion,
|
|
8682
8708
|
apiCall: api,
|
|
8709
|
+
soulApiCall,
|
|
8683
8710
|
dryRun: !!opts.dryRun,
|
|
8684
8711
|
});
|
|
8685
8712
|
const verb = opts.dryRun ? "(dry-run) would" : "";
|
|
@@ -8690,7 +8717,7 @@ rem
|
|
|
8690
8717
|
console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
|
|
8691
8718
|
console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
|
|
8692
8719
|
}
|
|
8693
|
-
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
|
|
8720
|
+
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls, ${result.deleted.candidates} candidates`);
|
|
8694
8721
|
console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
|
|
8695
8722
|
if (result.errors.length > 0) {
|
|
8696
8723
|
console.log(` Errors:`);
|
|
@@ -8737,16 +8764,32 @@ rem
|
|
|
8737
8764
|
// Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
|
|
8738
8765
|
// nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
|
|
8739
8766
|
// honored — lets ops pause fleet-wide without writing a file.
|
|
8767
|
+
// #1515: the same sentinel aborts an in-flight /ReflectMemories gather on
|
|
8768
|
+
// the Harper host (checked between yield points) so an operator can stop a
|
|
8769
|
+
// runaway run without restarting Harper.
|
|
8740
8770
|
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(() => {
|
|
8771
|
+
function writeRemPauseSentinel() {
|
|
8745
8772
|
const dir = dirname(REM_PAUSE_FLAG);
|
|
8746
8773
|
if (!existsSync(dir))
|
|
8747
8774
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8748
8775
|
writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
|
|
8776
|
+
}
|
|
8777
|
+
rem
|
|
8778
|
+
.command("pause")
|
|
8779
|
+
.description("Pause nightly REM runs and abort an in-flight distillation gather")
|
|
8780
|
+
.action(() => {
|
|
8781
|
+
writeRemPauseSentinel();
|
|
8749
8782
|
console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
|
|
8783
|
+
console.log(` In-flight distillation will abort at the next yield.`);
|
|
8784
|
+
console.log(` Resume with: flair rem resume`);
|
|
8785
|
+
});
|
|
8786
|
+
rem
|
|
8787
|
+
.command("abort")
|
|
8788
|
+
.description("Abort an in-flight REM distillation (same sentinel as pause)")
|
|
8789
|
+
.action(() => {
|
|
8790
|
+
writeRemPauseSentinel();
|
|
8791
|
+
console.log(`✅ REM abort requested (sentinel: ${REM_PAUSE_FLAG})`);
|
|
8792
|
+
console.log(` In-flight distillation will stop at the next yield; the scheduler stays paused.`);
|
|
8750
8793
|
console.log(` Resume with: flair rem resume`);
|
|
8751
8794
|
});
|
|
8752
8795
|
rem
|
|
@@ -8890,7 +8933,7 @@ export async function discoverLocalFlairPort(originalUrl) {
|
|
|
8890
8933
|
// pass it in, so this verified read signs as the SAME agent the rest of the
|
|
8891
8934
|
// command does. Undefined = resolve locally via the legacy flag>env pair (all
|
|
8892
8935
|
// other callers, unchanged).
|
|
8893
|
-
async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
8936
|
+
async function fetchHealthDetail(opts, signingAgentIdOverride, signingAgentSource) {
|
|
8894
8937
|
const port = resolveHttpPort(opts);
|
|
8895
8938
|
// --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
|
|
8896
8939
|
const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
|
|
@@ -8926,6 +8969,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
|
8926
8969
|
agentId: signingAgentIdOverride !== undefined
|
|
8927
8970
|
? (signingAgentIdOverride ?? undefined)
|
|
8928
8971
|
: (opts.agent || process.env.FLAIR_AGENT_ID),
|
|
8972
|
+
agentIdSource: signingAgentSource,
|
|
8929
8973
|
});
|
|
8930
8974
|
}
|
|
8931
8975
|
catch {
|
|
@@ -8944,8 +8988,8 @@ const statusCmd = program
|
|
|
8944
8988
|
.option("--json", "Output as JSON")
|
|
8945
8989
|
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
8946
8990
|
.action(async (opts) => {
|
|
8947
|
-
const statusAgentId = resolveSigningAgentId(opts, "status");
|
|
8948
|
-
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
|
|
8991
|
+
const { agentId: statusAgentId, source: statusSource } = resolveSigningAgentId(opts, "status");
|
|
8992
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId, statusSource);
|
|
8949
8993
|
// When unreachable on a localhost URL, probe candidate ports to detect
|
|
8950
8994
|
// config-vs-daemon port drift. Surface the actually-listening
|
|
8951
8995
|
// port with a fix recipe — better UX than just "unreachable."
|
|
@@ -10292,6 +10336,7 @@ program
|
|
|
10292
10336
|
.command("upgrade")
|
|
10293
10337
|
.description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
|
|
10294
10338
|
.option("--check", "Only check for updates / show the plan, don't install or deploy")
|
|
10339
|
+
.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
10340
|
.option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
|
|
10296
10341
|
.option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
|
|
10297
10342
|
.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 +10358,7 @@ program
|
|
|
10313
10358
|
// A colliding name is normally recoverable via optsWithGlobals(); this one is
|
|
10314
10359
|
// not, because commander's version listener exits the process. The name had
|
|
10315
10360
|
// 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)")
|
|
10361
|
+
.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
10362
|
.option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
|
|
10318
10363
|
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
10319
10364
|
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
@@ -10338,6 +10383,48 @@ program
|
|
|
10338
10383
|
const checkOnly = opts.check ?? false;
|
|
10339
10384
|
const showAll = opts.all ?? false;
|
|
10340
10385
|
console.log("Checking for updates...\n");
|
|
10386
|
+
// flair#1109 (a): if the serving tree (or --tree) is a packed extract,
|
|
10387
|
+
// take the in-place tarball lane instead of upgrading a leftover
|
|
10388
|
+
// npm-global relic. (b) still probes — and still prints — when we are
|
|
10389
|
+
// not taking that lane (git checkout, unknown path). Detection is
|
|
10390
|
+
// best-effort and never fails the command except an explicit --tree
|
|
10391
|
+
// that does not name a packed install (refuse, don't silently fall through).
|
|
10392
|
+
const upgradeServingPid = resolveInstanceServingPid(defaultDataDir(), resolveHttpPort({}));
|
|
10393
|
+
const upgradeNpmPrefix = await resolveNpmGlobalPrefix();
|
|
10394
|
+
let treeDecision = { kind: "skip" };
|
|
10395
|
+
try {
|
|
10396
|
+
treeDecision = resolvePlainTreeTarget({
|
|
10397
|
+
treeFlag: typeof opts.tree === "string" && opts.tree.trim() !== "" ? opts.tree.trim() : null,
|
|
10398
|
+
serving: upgradeServingPid != null ? resolveServingFlairPackage(upgradeServingPid) : null,
|
|
10399
|
+
cli: findFlairPackageDir(flairPackageDir()),
|
|
10400
|
+
global: resolveNpmGlobalFlairPackage(upgradeNpmPrefix, process.platform),
|
|
10401
|
+
});
|
|
10402
|
+
}
|
|
10403
|
+
catch { /* treat as skip — never fail the probe */ }
|
|
10404
|
+
if (treeDecision.kind === "refuse") {
|
|
10405
|
+
console.error(`❌ ${treeDecision.message}`);
|
|
10406
|
+
process.exit(1);
|
|
10407
|
+
}
|
|
10408
|
+
const treeLane = treeDecision.kind === "use" ? treeDecision.inspection : null;
|
|
10409
|
+
// flair#1109 (b): print the mismatch warning only when this run will
|
|
10410
|
+
// still treat npm-global as the install. Collect always, so the (b)
|
|
10411
|
+
// wiring test keeps seeing the call.
|
|
10412
|
+
try {
|
|
10413
|
+
const execPathWarning = collectUpgradeExecPathWarning({
|
|
10414
|
+
servingPid: upgradeServingPid,
|
|
10415
|
+
cliPackageDir: flairPackageDir(),
|
|
10416
|
+
npmGlobalPrefix: upgradeNpmPrefix,
|
|
10417
|
+
});
|
|
10418
|
+
if (execPathWarning && !treeLane) {
|
|
10419
|
+
console.log(execPathWarning);
|
|
10420
|
+
console.log("");
|
|
10421
|
+
}
|
|
10422
|
+
}
|
|
10423
|
+
catch { /* never fail upgrade over a path probe */ }
|
|
10424
|
+
if (treeLane) {
|
|
10425
|
+
console.log(formatPlainTreeBanner(treeLane));
|
|
10426
|
+
console.log("");
|
|
10427
|
+
}
|
|
10341
10428
|
const packages = [
|
|
10342
10429
|
{
|
|
10343
10430
|
name: "@tpsdev-ai/flair",
|
|
@@ -10380,11 +10467,33 @@ program
|
|
|
10380
10467
|
if (transitive && !showAll)
|
|
10381
10468
|
continue;
|
|
10382
10469
|
try {
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10470
|
+
let registryLatest = null;
|
|
10471
|
+
try {
|
|
10472
|
+
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
10473
|
+
if (res.ok) {
|
|
10474
|
+
const data = await res.json();
|
|
10475
|
+
registryLatest = typeof data.version === "string" && data.version ? data.version : null;
|
|
10476
|
+
}
|
|
10477
|
+
}
|
|
10478
|
+
catch { /* /latest timed out or failed — pin path must still work */ }
|
|
10479
|
+
let latest;
|
|
10480
|
+
if (treeLane && name === FLAIR_PKG_NAME) {
|
|
10481
|
+
// Consult registry latest, then apply --flair-version as the swap
|
|
10482
|
+
// target. A pin still applies when /latest is unavailable; without
|
|
10483
|
+
// that, a requested tarball swap reports up to date and does nothing.
|
|
10484
|
+
const listing = resolvePlainTreeListingTarget({
|
|
10485
|
+
registryLatest,
|
|
10486
|
+
pin: typeof opts.flairVersion === "string" ? opts.flairVersion : null,
|
|
10487
|
+
});
|
|
10488
|
+
if (!listing)
|
|
10489
|
+
continue;
|
|
10490
|
+
latest = listing.version;
|
|
10491
|
+
}
|
|
10492
|
+
else {
|
|
10493
|
+
if (!registryLatest)
|
|
10494
|
+
continue;
|
|
10495
|
+
latest = registryLatest;
|
|
10496
|
+
}
|
|
10388
10497
|
if (name === FLAIR_PKG_NAME && latest !== "unknown") {
|
|
10389
10498
|
try {
|
|
10390
10499
|
primeVersionCheckCache(latest);
|
|
@@ -10394,7 +10503,18 @@ program
|
|
|
10394
10503
|
const globalProbe = probe();
|
|
10395
10504
|
let installed;
|
|
10396
10505
|
let status;
|
|
10397
|
-
if (name ===
|
|
10506
|
+
if (treeLane && name === FLAIR_PKG_NAME) {
|
|
10507
|
+
// The serving/CLI packed tree is the install. A PATH or
|
|
10508
|
+
// require.resolve probe would report the npm-global relic.
|
|
10509
|
+
installed = treeLane.version;
|
|
10510
|
+
if (installed === null)
|
|
10511
|
+
status = "missing";
|
|
10512
|
+
else if (installed === latest)
|
|
10513
|
+
status = "current";
|
|
10514
|
+
else
|
|
10515
|
+
status = "outdated";
|
|
10516
|
+
}
|
|
10517
|
+
else if (name === FLAIR_MCP_PACKAGE) {
|
|
10398
10518
|
// flair-mcp is zero-install via npx (#1168) — a null global probe is
|
|
10399
10519
|
// the NORMAL state, not "missing". Resolve it from its actual wiring
|
|
10400
10520
|
// (the pin in a client MCP config / the SessionStart hook) so the
|
|
@@ -10436,7 +10556,12 @@ program
|
|
|
10436
10556
|
}
|
|
10437
10557
|
// Scope footer: make explicit what `flair upgrade` does and
|
|
10438
10558
|
// doesn't cover, so "were the others checked?" has a one-line answer.
|
|
10439
|
-
|
|
10559
|
+
if (treeLane) {
|
|
10560
|
+
console.log(`\n${formatPlainTreeScopeFooter(treeLane)}`);
|
|
10561
|
+
}
|
|
10562
|
+
else {
|
|
10563
|
+
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).");
|
|
10564
|
+
}
|
|
10440
10565
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
10441
10566
|
const missing = findings.filter((f) => f.status === "missing");
|
|
10442
10567
|
// flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
|
|
@@ -10457,6 +10582,20 @@ program
|
|
|
10457
10582
|
.filter((f) => f.kind === "openclaw-plugin")
|
|
10458
10583
|
.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
|
|
10459
10584
|
const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
|
|
10585
|
+
let treePlan = null;
|
|
10586
|
+
if (treeLane) {
|
|
10587
|
+
const flairFindingForPlan = findings.find((f) => f.name === FLAIR_PKG_NAME);
|
|
10588
|
+
treePlan = planPlainTreeUpgrade({
|
|
10589
|
+
treeDir: treeLane.dir,
|
|
10590
|
+
fromVersion: treeLane.version,
|
|
10591
|
+
toVersion: flairFindingForPlan?.latest ?? treeLane.version ?? "unknown",
|
|
10592
|
+
systemdUnits: findSystemdUnitsForTree(treeLane.dir),
|
|
10593
|
+
});
|
|
10594
|
+
if (flairFindingForPlan?.status === "outdated") {
|
|
10595
|
+
console.log("");
|
|
10596
|
+
console.log(formatPlainTreePlan(treePlan));
|
|
10597
|
+
}
|
|
10598
|
+
}
|
|
10460
10599
|
if (outdated.length === 0 && missing.length === 0) {
|
|
10461
10600
|
console.log("\n✅ Everything is up to date.");
|
|
10462
10601
|
return;
|
|
@@ -10515,6 +10654,19 @@ program
|
|
|
10515
10654
|
const result = client.wire(env);
|
|
10516
10655
|
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
10517
10656
|
}
|
|
10657
|
+
// flair#1516: the SessionStart hook command carries the SAME
|
|
10658
|
+
// @tpsdev-ai/flair-mcp@<version> pin as the client MCP block, but only
|
|
10659
|
+
// the client block was refreshed above — so an upgraded user kept
|
|
10660
|
+
// launching the PREVIOUS adapter on every session, silently, while
|
|
10661
|
+
// `flair doctor` reported the hook "still runs". Re-pin every ALREADY-
|
|
10662
|
+
// wired hook to the current spec too (never adds one — that stays an
|
|
10663
|
+
// opt-in). Best-effort, same as the client refresh.
|
|
10664
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
10665
|
+
const repin = repinSessionStartHook(homedir(), harness);
|
|
10666
|
+
if (repin.action === "update") {
|
|
10667
|
+
console.log(` ${repin.ok ? "✓" : "•"} ${repin.message}`);
|
|
10668
|
+
}
|
|
10669
|
+
}
|
|
10518
10670
|
}
|
|
10519
10671
|
// Nothing to install via npm/openclaw. What is left is advisory (packages
|
|
10520
10672
|
// not detected) and/or a flair-mcp whose wired pin is behind latest. The
|
|
@@ -10548,7 +10700,10 @@ program
|
|
|
10548
10700
|
return;
|
|
10549
10701
|
}
|
|
10550
10702
|
if (checkOnly) {
|
|
10551
|
-
|
|
10703
|
+
const treeHint = typeof opts.tree === "string" && opts.tree.trim() !== ""
|
|
10704
|
+
? ` --tree ${opts.tree.trim()}`
|
|
10705
|
+
: treeLane ? ` --tree ${treeLane.dir}` : "";
|
|
10706
|
+
console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade${treeHint}`);
|
|
10552
10707
|
if (missing.length > 0) {
|
|
10553
10708
|
console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
|
|
10554
10709
|
}
|
|
@@ -10662,7 +10817,7 @@ program
|
|
|
10662
10817
|
let currentEngineVersion = null;
|
|
10663
10818
|
let targetEngineVersion = null;
|
|
10664
10819
|
if (flairIsUpgrading && hasDataDir) {
|
|
10665
|
-
currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
|
|
10820
|
+
currentEngineVersion = readInstalledHarperVersion(treeLane?.dir ?? flairPackageDir());
|
|
10666
10821
|
const targetFlairVersion = flairFinding?.latest;
|
|
10667
10822
|
if (targetFlairVersion && currentEngineVersion) {
|
|
10668
10823
|
targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
|
|
@@ -10723,6 +10878,12 @@ program
|
|
|
10723
10878
|
let flairInstallFailed = false;
|
|
10724
10879
|
for (const { pkg, latest } of npmUpgrades) {
|
|
10725
10880
|
try {
|
|
10881
|
+
if (treePlan && pkg === FLAIR_PKG_NAME) {
|
|
10882
|
+
console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
|
|
10883
|
+
await applyPlainTreeUpgrade(treePlan);
|
|
10884
|
+
console.log(` ✅ ${pkg}@${latest} installed (plain-tree swap; previous tree at ${treePlan.previousDir})`);
|
|
10885
|
+
continue;
|
|
10886
|
+
}
|
|
10726
10887
|
console.log(` Installing ${pkg}@${latest}...`);
|
|
10727
10888
|
execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
|
|
10728
10889
|
console.log(` ✅ ${pkg}@${latest} installed`);
|
|
@@ -10785,6 +10946,9 @@ program
|
|
|
10785
10946
|
}
|
|
10786
10947
|
if (!shouldRestart) {
|
|
10787
10948
|
console.log("\nRun: flair restart to use the new version");
|
|
10949
|
+
if (treePlan) {
|
|
10950
|
+
console.log(`Previous tree kept at ${treePlan.previousDir} until you restart and verify.`);
|
|
10951
|
+
}
|
|
10788
10952
|
return;
|
|
10789
10953
|
}
|
|
10790
10954
|
console.log("\nRestarting Flair...");
|
|
@@ -10805,12 +10969,31 @@ program
|
|
|
10805
10969
|
const rollbackTo = async (toVersion, reason) => {
|
|
10806
10970
|
console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
|
|
10807
10971
|
try {
|
|
10808
|
-
|
|
10972
|
+
if (treePlan) {
|
|
10973
|
+
const rollbackDecision = decidePlainTreeRollback(existsSync(treePlan.previousDir));
|
|
10974
|
+
if (rollbackDecision.kind === "restore") {
|
|
10975
|
+
if (!restorePlainTreePrevious(treePlan)) {
|
|
10976
|
+
throw new Error(`no previous tree at ${treePlan.previousDir} to restore`);
|
|
10977
|
+
}
|
|
10978
|
+
console.log(` ✅ restored previous tree from ${treePlan.previousDir}`);
|
|
10979
|
+
}
|
|
10980
|
+
else {
|
|
10981
|
+
console.log(` (${rollbackDecision.reason})`);
|
|
10982
|
+
}
|
|
10983
|
+
}
|
|
10984
|
+
else {
|
|
10985
|
+
execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
|
|
10986
|
+
}
|
|
10809
10987
|
}
|
|
10810
10988
|
catch (err) {
|
|
10811
10989
|
console.error(`❌ rollback install failed: ${err.message}`);
|
|
10812
10990
|
console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
|
|
10813
|
-
|
|
10991
|
+
const prevExists = !!(treePlan && existsSync(treePlan.previousDir));
|
|
10992
|
+
console.error(treePlan
|
|
10993
|
+
? (prevExists
|
|
10994
|
+
? ` Recover by hand: restore ${treePlan.previousDir} to ${treePlan.treeDir} && flair start`
|
|
10995
|
+
: ` The live tree at ${treePlan.treeDir} was not swapped; there is no .upgrade-prev to restore. Start it with: flair start`)
|
|
10996
|
+
: ` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
|
|
10814
10997
|
process.exit(1);
|
|
10815
10998
|
}
|
|
10816
10999
|
// flair#1053: when the engine (Harper) version changed, the pre-upgrade
|
|
@@ -10859,9 +11042,16 @@ program
|
|
|
10859
11042
|
}
|
|
10860
11043
|
// Same post-swap rule as the upgrade restart above: the rolled-back
|
|
10861
11044
|
// version's own CLI is the thing that knows how to start it.
|
|
10862
|
-
const
|
|
11045
|
+
const rolledBackRoot = treePlan?.treeDir ?? flairPackageDir();
|
|
11046
|
+
const rolledBackCli = resolveInstalledFlairCli(rolledBackRoot, toVersion);
|
|
10863
11047
|
try {
|
|
10864
|
-
|
|
11048
|
+
if (treePlan && treePlan.systemdUnits.length > 0) {
|
|
11049
|
+
console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
|
|
11050
|
+
restartSystemdUnits(treePlan.systemdUnits);
|
|
11051
|
+
}
|
|
11052
|
+
else {
|
|
11053
|
+
await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
|
|
11054
|
+
}
|
|
10865
11055
|
}
|
|
10866
11056
|
catch (err) {
|
|
10867
11057
|
console.error(`❌ rollback restart failed: ${err.message}`);
|
|
@@ -10914,9 +11104,10 @@ program
|
|
|
10914
11104
|
// from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
|
|
10915
11105
|
// tree can't be verified) falls back to an in-process restart, announced.
|
|
10916
11106
|
const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
|
|
11107
|
+
const swappedPackageRoot = treePlan?.treeDir ?? flairPackageDir();
|
|
10917
11108
|
let newCli = null;
|
|
10918
11109
|
if (flairWasSwapped) {
|
|
10919
|
-
const resolved = resolveInstalledFlairCli(
|
|
11110
|
+
const resolved = resolveInstalledFlairCli(swappedPackageRoot, expectedFlairVersion);
|
|
10920
11111
|
if (resolved.ok === false) {
|
|
10921
11112
|
console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
|
|
10922
11113
|
}
|
|
@@ -10926,7 +11117,15 @@ program
|
|
|
10926
11117
|
}
|
|
10927
11118
|
let restartWasDelegated = false;
|
|
10928
11119
|
try {
|
|
10929
|
-
|
|
11120
|
+
if (treePlan && treePlan.systemdUnits.length > 0) {
|
|
11121
|
+
console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
|
|
11122
|
+
restartSystemdUnits(treePlan.systemdUnits);
|
|
11123
|
+
restartWasDelegated = true;
|
|
11124
|
+
console.log("✅ Flair restarted (systemd unit)");
|
|
11125
|
+
}
|
|
11126
|
+
else {
|
|
11127
|
+
restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
|
|
11128
|
+
}
|
|
10930
11129
|
}
|
|
10931
11130
|
catch (err) {
|
|
10932
11131
|
console.error(`❌ restart failed: ${err.message}`);
|
|
@@ -10963,6 +11162,9 @@ program
|
|
|
10963
11162
|
const detached = isDetached(management);
|
|
10964
11163
|
if (!shouldVerify) {
|
|
10965
11164
|
console.log(" (--no-verify: skipping post-restart verification)");
|
|
11165
|
+
if (treePlan) {
|
|
11166
|
+
console.log(` Previous tree kept at ${treePlan.previousDir} (rollback source; not discarded without verify).`);
|
|
11167
|
+
}
|
|
10966
11168
|
if (detached) {
|
|
10967
11169
|
for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
|
|
10968
11170
|
console.error(line);
|
|
@@ -10996,6 +11198,8 @@ program
|
|
|
10996
11198
|
toVersion: expectedFlairVersion,
|
|
10997
11199
|
});
|
|
10998
11200
|
printVerifiedSummary(renderVerifiedSummary(verify.version, run));
|
|
11201
|
+
if (treePlan)
|
|
11202
|
+
discardPlainTreePrevious(treePlan.previousDir);
|
|
10999
11203
|
return;
|
|
11000
11204
|
}
|
|
11001
11205
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -11026,6 +11230,8 @@ program
|
|
|
11026
11230
|
console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
|
|
11027
11231
|
console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
|
|
11028
11232
|
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.");
|
|
11233
|
+
if (treePlan)
|
|
11234
|
+
discardPlainTreePrevious(treePlan.previousDir);
|
|
11029
11235
|
return;
|
|
11030
11236
|
}
|
|
11031
11237
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -11703,6 +11909,253 @@ function observeLaunchdManagement(dataDir, port) {
|
|
|
11703
11909
|
list: realLaunchctlLister,
|
|
11704
11910
|
});
|
|
11705
11911
|
}
|
|
11912
|
+
/**
|
|
11913
|
+
* Build the launchd plist for a `doctor --fix` repair (flair#1573 slice b).
|
|
11914
|
+
*
|
|
11915
|
+
* Deliberately DIVERGES from the `flair init` plist in one way that matters:
|
|
11916
|
+
* it always uses the pass-file (secret-free) mode, so the regenerated plist
|
|
11917
|
+
* never embeds HDB_ADMIN_PASSWORD inline — the exact regression this issue
|
|
11918
|
+
* exists to prevent. The ports and ROOTPATH come from the instance's own
|
|
11919
|
+
* harper-config.yaml (config authority, flair#914), never ~/.flair/config.yaml
|
|
11920
|
+
* or defaults, so the repair cannot re-bootstrap Harper against a different
|
|
11921
|
+
* directory or port.
|
|
11922
|
+
*
|
|
11923
|
+
* `config` is the parsed harper-config.yaml, already gated readable by the
|
|
11924
|
+
* caller. Throws when the Harper binary cannot be resolved — a plist pointing
|
|
11925
|
+
* at a missing binary is the stale-plist failure this repair must not write.
|
|
11926
|
+
*/
|
|
11927
|
+
function buildRepairPlist(dataDir, config) {
|
|
11928
|
+
// The DEFAULT_PORT / httpPort-1 fallbacks below are practically unreachable:
|
|
11929
|
+
// Harper writes harper-config.yaml ports on every boot (flair#914), so a
|
|
11930
|
+
// readable config always carries them. They exist only as a last resort.
|
|
11931
|
+
const httpPort = harperPortValue(config?.http?.port) ?? DEFAULT_PORT;
|
|
11932
|
+
const opsPortRaw = config?.operationsApi?.network?.port;
|
|
11933
|
+
const opsPort = harperPortValue(opsPortRaw) ?? (httpPort - 1);
|
|
11934
|
+
const opsBind = detectOpsApiAllInterfacesBind(opsPortRaw);
|
|
11935
|
+
const opsBindHost = opsBind.boundHost ?? "127.0.0.1";
|
|
11936
|
+
const opsSocket = join(dataDir, "operations-server");
|
|
11937
|
+
// Preserve the config's exact ops-port form (host-qualified or bare) so the
|
|
11938
|
+
// regenerated plist neither re-narrows nor re-widens the bind — the
|
|
11939
|
+
// no-re-bootstrap guarantee is mechanical, not best-effort.
|
|
11940
|
+
const opsNetworkPort = typeof opsPortRaw === "string" && opsPortRaw.trim() !== ""
|
|
11941
|
+
? opsPortRaw.trim()
|
|
11942
|
+
: opsNetworkPortValue(opsBindHost, opsPort);
|
|
11943
|
+
const setConfig = JSON.stringify({
|
|
11944
|
+
rootPath: dataDir,
|
|
11945
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
11946
|
+
operationsApi: { network: { port: opsNetworkPort, cors: true, domainSocket: opsSocket } },
|
|
11947
|
+
mqtt: MQTT_DISABLED_CONFIG,
|
|
11948
|
+
localStudio: { enabled: false },
|
|
11949
|
+
authentication: { authorizeLocal: false, enableSessions: true },
|
|
11950
|
+
});
|
|
11951
|
+
const harperBinPath = harperBin();
|
|
11952
|
+
if (!harperBinPath)
|
|
11953
|
+
throw new Error(harperBinNotFoundMessage(harperSearchRoots()));
|
|
11954
|
+
const label = launchdLabel(dataDir);
|
|
11955
|
+
const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
|
|
11956
|
+
return buildLaunchdPlist({
|
|
11957
|
+
label,
|
|
11958
|
+
execPath: process.execPath,
|
|
11959
|
+
harperBinPath,
|
|
11960
|
+
workingDirectory: flairPackageDir(),
|
|
11961
|
+
dataDir,
|
|
11962
|
+
modelsDir,
|
|
11963
|
+
setConfig,
|
|
11964
|
+
adminUser: DEFAULT_ADMIN_USER,
|
|
11965
|
+
adminPass: "", // ignored in pass-file mode
|
|
11966
|
+
httpPort,
|
|
11967
|
+
opsNetworkPort,
|
|
11968
|
+
passFile: {
|
|
11969
|
+
launcher: launchdLauncherPath(),
|
|
11970
|
+
adminPassFile: defaultAdminPassPath(),
|
|
11971
|
+
home: homedir(),
|
|
11972
|
+
path: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
11973
|
+
},
|
|
11974
|
+
});
|
|
11975
|
+
}
|
|
11976
|
+
/**
|
|
11977
|
+
* Compute the launchd repair plan for `dataDir` (flair#1573 slice b) WITHOUT
|
|
11978
|
+
* executing it — the detect + classify + decide half. The doctor command uses
|
|
11979
|
+
* this for dry-run / non-`--fix` reporting; `repairLaunchdManagement` (below)
|
|
11980
|
+
* reuses it and then executes a `regenerate` or `adopt` plan.
|
|
11981
|
+
*/
|
|
11982
|
+
function planLaunchdRepairFor(dataDir, port) {
|
|
11983
|
+
// Config authority gate (flair#914): the whole fix is gated on the
|
|
11984
|
+
// instance's own harper-config.yaml being readable.
|
|
11985
|
+
const config = harperConfigPath(dataDir) ? readHarperConfig(dataDir) : null;
|
|
11986
|
+
const configReadable = config !== null;
|
|
11987
|
+
// Observe the current state.
|
|
11988
|
+
const observation = observeLaunchdManagement(dataDir, port);
|
|
11989
|
+
// Classify the plist disposition (ownership guard's first question).
|
|
11990
|
+
const { plistPath, isLegacy } = resolveLaunchdLabel(dataDir);
|
|
11991
|
+
const disposition = classifyPlist(plistPath, dataDir, {
|
|
11992
|
+
exists: existsSync,
|
|
11993
|
+
read: (p) => { try {
|
|
11994
|
+
return readFileSync(p, "utf-8");
|
|
11995
|
+
}
|
|
11996
|
+
catch {
|
|
11997
|
+
return null;
|
|
11998
|
+
} },
|
|
11999
|
+
readRootPath: readPlistRootPath,
|
|
12000
|
+
});
|
|
12001
|
+
// Is a direct (non-launchd) process serving this instance right now?
|
|
12002
|
+
const instancePid = resolveInstanceServingPid(dataDir, port);
|
|
12003
|
+
const directProcessRunning = instancePid !== null && observation.state !== "managed";
|
|
12004
|
+
const plan = planLaunchdRepair({ observation, disposition, plistPath, directProcessRunning, configReadable });
|
|
12005
|
+
return { plan, plistPath, isLegacy, config };
|
|
12006
|
+
}
|
|
12007
|
+
/**
|
|
12008
|
+
* Repair launchd management for `dataDir` (flair#1573 slice b) — the
|
|
12009
|
+
* `doctor --fix` launchd repair for a MISSING, CORRUPT, or DETACHED plist.
|
|
12010
|
+
*
|
|
12011
|
+
* detect -> (adopt: clean-stop the direct process) -> regenerate (pass-file
|
|
12012
|
+
* mode) -> load -> verify. The DECISION (state matrix + ownership guard +
|
|
12013
|
+
* config authority) lives in planLaunchdRepair (src/lib/launchd-repair.ts);
|
|
12014
|
+
* this is the EXECUTION, and it is the only place that touches the real
|
|
12015
|
+
* filesystem and launchctl.
|
|
12016
|
+
*
|
|
12017
|
+
* Reuses the existing primitives rather than re-inventing them:
|
|
12018
|
+
* - observeLaunchdManagement / assessLaunchdManagement is the fail-loud
|
|
12019
|
+
* verifier (launchctl PID AND that PID is the serving process).
|
|
12020
|
+
* - ensureLaunchdServiceLoaded is the unload -> load -> start.
|
|
12021
|
+
* - the five-state liveness machine (gatherDaemonEvidence +
|
|
12022
|
+
* classifyDaemonState, flair#1454) attributes and clean-stops the direct
|
|
12023
|
+
* process on the adopt path — never a foreign/recycled pid, never kill -9.
|
|
12024
|
+
*
|
|
12025
|
+
* The adopt path (flair#1573 slice b2) BOUNCES the live instance: it
|
|
12026
|
+
* clean-stops the direct-spawned process (SIGTERM + wait for exit), confirms
|
|
12027
|
+
* the port is free, then regenerates + loads. A foreign/unattributable direct
|
|
12028
|
+
* process is refused by the liveness machine (DISAGREEMENT/UNKNOWN), never
|
|
12029
|
+
* signalled.
|
|
12030
|
+
*
|
|
12031
|
+
* Never reports success on a direct-start fallback: the final verify is
|
|
12032
|
+
* assessLaunchdManagement, and anything short of `managed` is a `failed` result
|
|
12033
|
+
* with the detached detail + remedy, never a silent pass. The whole executor
|
|
12034
|
+
* arm is wrapped in try/catch (Kern's b1 defect): a throw becomes a named
|
|
12035
|
+
* `failed` result (or an engine-backwards `refused`), never a crash mid-report.
|
|
12036
|
+
*/
|
|
12037
|
+
async function repairLaunchdManagement(dataDir, port) {
|
|
12038
|
+
const { plan, plistPath, isLegacy, config } = planLaunchdRepairFor(dataDir, port);
|
|
12039
|
+
switch (plan.kind) {
|
|
12040
|
+
case "no-op":
|
|
12041
|
+
return { kind: "no-op", reason: plan.reason, detail: plan.detail };
|
|
12042
|
+
case "refuse":
|
|
12043
|
+
return { kind: "refused", reason: plan.reason, detail: plan.detail, plistPath: plan.plistPath };
|
|
12044
|
+
case "adopt":
|
|
12045
|
+
case "regenerate": {
|
|
12046
|
+
try {
|
|
12047
|
+
// Guard FIRST (flair#1093): the repair is a boot path, and an older
|
|
12048
|
+
// engine opening a newer store fails at the storage layer minutes
|
|
12049
|
+
// later — same refusal as startFlairProcess. On the adopt arm this
|
|
12050
|
+
// must run BEFORE the stop: it is a pure read whose inputs don't
|
|
12051
|
+
// change during the repair, so guard-first refuses WITHOUT bouncing
|
|
12052
|
+
// the live instance (guard-after-stop would SIGTERM the instance and
|
|
12053
|
+
// then refuse, leaving it down with nothing to restart it).
|
|
12054
|
+
guardEngineNotBackwards(dataDir);
|
|
12055
|
+
// Adopt (flair#1573 slice b2): clean-stop the direct process first, so
|
|
12056
|
+
// the regenerate + load below does not collide on the port.
|
|
12057
|
+
if (plan.kind === "adopt") {
|
|
12058
|
+
const stop = await stopDirectProcessForAdopt(port, dataDir);
|
|
12059
|
+
if (stop)
|
|
12060
|
+
return stop; // a named failed result
|
|
12061
|
+
}
|
|
12062
|
+
const { execSync } = await import("node:child_process");
|
|
12063
|
+
// Regenerate the plist (pass-file mode) and write it atomically.
|
|
12064
|
+
// No secret is embedded, so 0644 is correct here.
|
|
12065
|
+
const plist = buildRepairPlist(dataDir, config);
|
|
12066
|
+
const newPlistPath = launchdPlistPath(launchdLabel(dataDir));
|
|
12067
|
+
writeFileAtomic(newPlistPath, plist, 0o644);
|
|
12068
|
+
// flair#1586 / #1581: a SET_CONFIG-less detach (MQTT_* via
|
|
12069
|
+
// buildDirectSpawnEnv) can persist mqtt.network as mtls, port,
|
|
12070
|
+
// securePort when Harper stored no originals for already-null ports.
|
|
12071
|
+
// Adopt SET_CONFIG updates those keys in place and would otherwise
|
|
12072
|
+
// leave harper-config.yaml not byte-identical to the first-repair
|
|
12073
|
+
// file (port, securePort, mtls). Reorder only those scalar lines
|
|
12074
|
+
// before launchd loads so the next persist matches the settled file.
|
|
12075
|
+
const cfgPath = harperConfigPath(dataDir);
|
|
12076
|
+
if (cfgPath) {
|
|
12077
|
+
const raw = readFileSync(cfgPath, "utf-8");
|
|
12078
|
+
const { text, changed } = stabilizeMqttNetworkKeyOrder(raw);
|
|
12079
|
+
if (changed)
|
|
12080
|
+
writeFileAtomic(cfgPath, text, 0o644);
|
|
12081
|
+
}
|
|
12082
|
+
// If the resolved plist was a pre-flair#693 legacy label, unload and
|
|
12083
|
+
// remove it so it is not orphaned beside the regenerated one.
|
|
12084
|
+
if (isLegacy && plistPath !== newPlistPath) {
|
|
12085
|
+
try {
|
|
12086
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
12087
|
+
}
|
|
12088
|
+
catch { /* best effort */ }
|
|
12089
|
+
try {
|
|
12090
|
+
unlinkSync(plistPath);
|
|
12091
|
+
}
|
|
12092
|
+
catch { /* best effort */ }
|
|
12093
|
+
}
|
|
12094
|
+
// Load (unload -> load -> start).
|
|
12095
|
+
ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
12096
|
+
// Verify (fail-loud).
|
|
12097
|
+
const after = observeLaunchdManagement(dataDir, port);
|
|
12098
|
+
if (after.state !== "managed") {
|
|
12099
|
+
return { kind: "failed", detail: after.detail, remedy: after.remedy };
|
|
12100
|
+
}
|
|
12101
|
+
const detail = plan.kind === "adopt"
|
|
12102
|
+
? `adopted the direct-spawned instance into launchd (bounced the live instance): ${after.detail}`
|
|
12103
|
+
: after.detail;
|
|
12104
|
+
return { kind: "repaired", detail };
|
|
12105
|
+
}
|
|
12106
|
+
catch (err) {
|
|
12107
|
+
return mapRepairThrow(err);
|
|
12108
|
+
}
|
|
12109
|
+
}
|
|
12110
|
+
}
|
|
12111
|
+
}
|
|
12112
|
+
/**
|
|
12113
|
+
* Clean-stop the direct (non-launchd) process serving `dataDir`+`port` for the
|
|
12114
|
+
* adopt path (flair#1573 slice b2). Returns a `failed` result when the process
|
|
12115
|
+
* cannot be attributed (never stop a foreign process) or the port is still
|
|
12116
|
+
* occupied after the stop; returns null when the port is free and the caller
|
|
12117
|
+
* should proceed to regenerate + load.
|
|
12118
|
+
*
|
|
12119
|
+
* Reuses the five-state liveness machine (flair#1454): identity is verified
|
|
12120
|
+
* (pidfile + sidecar + start time) before any signal, so a DISAGREEMENT/UNKNOWN
|
|
12121
|
+
* verdict refuses rather than gambling on a recycled pid. The stop is SIGTERM +
|
|
12122
|
+
* wait for exit — never kill -9.
|
|
12123
|
+
*/
|
|
12124
|
+
async function stopDirectProcessForAdopt(port, dataDir) {
|
|
12125
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
12126
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
12127
|
+
// SIGTERM + wait for exit for a verified live pid (RUNNING or WEDGED — a
|
|
12128
|
+
// wedged daemon is recovery, not a recycled-pid gamble).
|
|
12129
|
+
if (state.state === "RUNNING" || state.state === "WEDGED") {
|
|
12130
|
+
try {
|
|
12131
|
+
process.kill(state.pid, "SIGTERM");
|
|
12132
|
+
}
|
|
12133
|
+
catch { /* already gone */ }
|
|
12134
|
+
try {
|
|
12135
|
+
await waitForProcessExit(state.pid, STARTUP_TIMEOUT_MS);
|
|
12136
|
+
}
|
|
12137
|
+
catch { /* best-effort — the port check below surfaces the real problem */ }
|
|
12138
|
+
}
|
|
12139
|
+
const postStopHealth = await probeHealth(port);
|
|
12140
|
+
const decision = decideAdoptStop(state, postStopHealth);
|
|
12141
|
+
if (decision !== "proceed")
|
|
12142
|
+
return decision;
|
|
12143
|
+
// Belt-and-suspenders: lsof confirms no TCP listener remains before the
|
|
12144
|
+
// caller loads the plist. probeHealth "refused" (ECONNREFUSED) already means
|
|
12145
|
+
// nothing is listening, but a port that is BOUND yet refuses connections
|
|
12146
|
+
// (backlog-full, or a non-HTTP listener) would still EADDRINUSE on load —
|
|
12147
|
+
// this catches that rare case the HTTP probe cannot see.
|
|
12148
|
+
const { execSync } = await import("node:child_process");
|
|
12149
|
+
const listeners = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
12150
|
+
if (listeners.length > 0) {
|
|
12151
|
+
return {
|
|
12152
|
+
kind: "failed",
|
|
12153
|
+
detail: `port still occupied after stopping the direct process (listener pid ${listeners.join(", ")})`,
|
|
12154
|
+
remedy: ["flair stop", "flair doctor --fix"],
|
|
12155
|
+
};
|
|
12156
|
+
}
|
|
12157
|
+
return null;
|
|
12158
|
+
}
|
|
11706
12159
|
/**
|
|
11707
12160
|
* Stop the local Flair (Harper) process — launchd `stop` on darwin when a
|
|
11708
12161
|
* plist is present (falling back on failure), otherwise a manual SIGTERM by
|
|
@@ -12298,8 +12751,21 @@ program
|
|
|
12298
12751
|
// is actually stamping new writes with.
|
|
12299
12752
|
const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
|
|
12300
12753
|
const EMBEDDING_VARIANT = "searchprefix";
|
|
12754
|
+
// embedding-space-guard slice 1: getModelId() now stamps the ENGINE-QUALIFIED
|
|
12755
|
+
// id `<engine>:<base>[+searchprefix]`. Duplicated as a literal here (separate
|
|
12756
|
+
// build target — see above). A row is CURRENT-SPACE iff its stamp is the
|
|
12757
|
+
// qualified id OR its one-time bare-name equivalent (today's corpus, stamped
|
|
12758
|
+
// before the qualifier). Treat BOTH as current so `--stale-only` never
|
|
12759
|
+
// re-embeds an already-correct bare-stamped row — that would loop forever
|
|
12760
|
+
// (Memory.put re-stamps it QUALIFIED, still "!= bare" under a single-value
|
|
12761
|
+
// check). Keep in lockstep with resources/embeddings-provider.ts's
|
|
12762
|
+
// getModelId()/EMBEDDING_ENGINE and resources/embedding-space-guard.ts's
|
|
12763
|
+
// normalizeStamp().
|
|
12764
|
+
const EMBEDDING_ENGINE = "gguf";
|
|
12301
12765
|
const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
12302
|
-
const
|
|
12766
|
+
const bareCurrentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
|
|
12767
|
+
const currentModel = `${EMBEDDING_ENGINE}:${bareCurrentModel}`;
|
|
12768
|
+
const isCurrentSpace = (stamp) => stamp === currentModel || stamp === bareCurrentModel;
|
|
12303
12769
|
if (agentId) {
|
|
12304
12770
|
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
12305
12771
|
}
|
|
@@ -12358,7 +12824,7 @@ program
|
|
|
12358
12824
|
for (const m of allMemories) {
|
|
12359
12825
|
if (!m.content)
|
|
12360
12826
|
continue;
|
|
12361
|
-
if (staleOnly && m.embeddingModel
|
|
12827
|
+
if (staleOnly && isCurrentSpace(m.embeddingModel))
|
|
12362
12828
|
continue;
|
|
12363
12829
|
const agent = m.agentId || "unknown";
|
|
12364
12830
|
if (!byAgent.has(agent))
|
|
@@ -12463,7 +12929,7 @@ program
|
|
|
12463
12929
|
if (!m.content)
|
|
12464
12930
|
return false;
|
|
12465
12931
|
if (staleOnly)
|
|
12466
|
-
return !m.embeddingModel || m.embeddingModel
|
|
12932
|
+
return !m.embeddingModel || !isCurrentSpace(m.embeddingModel);
|
|
12467
12933
|
return true;
|
|
12468
12934
|
});
|
|
12469
12935
|
const total = candidates.length;
|
|
@@ -13441,16 +13907,22 @@ program
|
|
|
13441
13907
|
// catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
|
|
13442
13908
|
// both. Extra doctor UX (pi, --fix, execution probe, continuity,
|
|
13443
13909
|
// agent registration) stays below and does not redefine those checks.
|
|
13910
|
+
//
|
|
13911
|
+
// flair#1573 slice b — launchd management is diagnosed + repaired by its
|
|
13912
|
+
// own section below (planLaunchdRepairFor / repairLaunchdManagement), not
|
|
13913
|
+
// by the install-health catalog. The catalog's launchd check stays for
|
|
13914
|
+
// `upgrade` (flair#1022), but doctor would otherwise double-count the same
|
|
13915
|
+
// drift (catalog "detached" fail + repair "regenerate"/"adopt"/"refuse").
|
|
13916
|
+
const doctorCatalogIds = DOCTOR_CHECK_IDS.filter((id) => id !== "launchd-management");
|
|
13444
13917
|
const doctorCtx = {
|
|
13445
13918
|
homeDir: homedir(),
|
|
13446
13919
|
cwd: process.cwd(),
|
|
13447
13920
|
detectedClientIds: detectedClients.map((c) => c.id),
|
|
13448
|
-
launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
|
|
13449
13921
|
keysDir,
|
|
13450
13922
|
keyAgentIds,
|
|
13451
13923
|
agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
|
|
13452
13924
|
};
|
|
13453
|
-
const catalogBefore = runDoctorChecks(doctorCtx);
|
|
13925
|
+
const catalogBefore = runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds });
|
|
13454
13926
|
if (detectedClients.length === 0) {
|
|
13455
13927
|
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
13456
13928
|
}
|
|
@@ -13469,170 +13941,13 @@ program
|
|
|
13469
13941
|
}
|
|
13470
13942
|
}
|
|
13471
13943
|
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
|
-
}
|
|
13944
|
+
// flair#989 — pi is a dead namespace: the pi (kind:
|
|
13945
|
+
// "native-extension") check is removed from doctor entirely. pi was
|
|
13946
|
+
// the last non-MCP client here, and a detected-but-unwired pi was
|
|
13947
|
+
// counted as an install failure for a namespace nobody opts into any
|
|
13948
|
+
// more. Doctor now diagnoses only MCP clients the user wired (below).
|
|
13949
|
+
if (client.kind !== "mcp")
|
|
13634
13950
|
continue;
|
|
13635
|
-
}
|
|
13636
13951
|
const block = readClientMcpBlock(client.id, homedir());
|
|
13637
13952
|
if (client.id === "claude-code" && block.agentId)
|
|
13638
13953
|
claudeCodeAgentId = block.agentId;
|
|
@@ -13641,7 +13956,12 @@ program
|
|
|
13641
13956
|
if (block.agentId)
|
|
13642
13957
|
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
13643
13958
|
if (!block.present) {
|
|
13644
|
-
|
|
13959
|
+
// flair#989: this client is DETECTED (binary/config on the box) but
|
|
13960
|
+
// was never wired to Flair — the user did not opt into it. That is
|
|
13961
|
+
// not an install FAILURE, so it renders as info, never a ✗, and is
|
|
13962
|
+
// not counted (the catalog's opt-in mcp-block check owns the count).
|
|
13963
|
+
// `--fix` still offers to wire it, on the user's y/N consent.
|
|
13964
|
+
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
13965
|
if (autoFix) {
|
|
13646
13966
|
if (dryRun) {
|
|
13647
13967
|
console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
|
|
@@ -13702,7 +14022,7 @@ program
|
|
|
13702
14022
|
// `--fix` already works, so don't clutter the suggestion.
|
|
13703
14023
|
const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
13704
14024
|
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
13705
|
-
console.log(` ${render.wrap(render.c.dim, "
|
|
14025
|
+
console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
|
|
13706
14026
|
}
|
|
13707
14027
|
continue;
|
|
13708
14028
|
}
|
|
@@ -13739,10 +14059,25 @@ program
|
|
|
13739
14059
|
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
13740
14060
|
}
|
|
13741
14061
|
}
|
|
14062
|
+
// flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
|
|
14063
|
+
// hook, continuity, Codex hook) run only for a harness the user actually
|
|
14064
|
+
// WIRED — its MCP block is present. A harness merely DETECTED on the box
|
|
14065
|
+
// but never opted into owes none of these; flagging them was the false-
|
|
14066
|
+
// positive this fix removes. Read the block fresh so a `--fix` that just
|
|
14067
|
+
// wired the client during the loop above is reflected here.
|
|
14068
|
+
const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
|
|
14069
|
+
const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
|
|
14070
|
+
const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
|
|
13742
14071
|
// Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
|
|
13743
14072
|
// Codex has a SessionStart hook too (checked below); CLAUDE.md and
|
|
13744
14073
|
// continuity stay Claude Code only.
|
|
13745
|
-
|
|
14074
|
+
//
|
|
14075
|
+
// flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
|
|
14076
|
+
// they apply, and can only fail, once Claude Code is WIRED — so they are
|
|
14077
|
+
// gated on `claudeCodeConfigured`. Continuity (below) is a separate
|
|
14078
|
+
// opt-in that renders "not enabled" as info and never a failure, so it
|
|
14079
|
+
// stays gated on mere detection (flair#1324/#1257).
|
|
14080
|
+
if (claudeCodeConfigured) {
|
|
13746
14081
|
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
13747
14082
|
if (claudeMd.present) {
|
|
13748
14083
|
console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
|
|
@@ -13816,6 +14151,30 @@ program
|
|
|
13816
14151
|
else {
|
|
13817
14152
|
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
14153
|
}
|
|
14154
|
+
// flair#1516: a hook can be wired AND still run yet be pinned to a
|
|
14155
|
+
// DIFFERENT @tpsdev-ai/flair-mcp version than the Claude Code MCP
|
|
14156
|
+
// client — an upgrade refreshed the client block but (pre-#1516)
|
|
14157
|
+
// left the hook behind, so every session silently launched the OLD
|
|
14158
|
+
// adapter. "and still runs" never caught this; compare the two pins.
|
|
14159
|
+
const claudeHookSkew = checkSessionStartHookPinSkew(homedir(), "claude-code");
|
|
14160
|
+
if (claudeHookSkew.skewed) {
|
|
14161
|
+
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`);
|
|
14162
|
+
if (autoFix) {
|
|
14163
|
+
if (dryRun) {
|
|
14164
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
14165
|
+
}
|
|
14166
|
+
else {
|
|
14167
|
+
const repin = repinSessionStartHook(homedir(), "claude-code");
|
|
14168
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
14169
|
+
if (repin.ok && repin.action === "update")
|
|
14170
|
+
fixed++;
|
|
14171
|
+
}
|
|
14172
|
+
}
|
|
14173
|
+
else {
|
|
14174
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
|
|
14175
|
+
}
|
|
14176
|
+
issues++;
|
|
14177
|
+
}
|
|
13819
14178
|
// Independent of whether it runs today: would it stay quiet if it
|
|
13820
14179
|
// stopped? Only offered as a repair when the command is the exact
|
|
13821
14180
|
// string Flair itself wrote — a hand-edited or pinned hook is the
|
|
@@ -13869,6 +14228,10 @@ program
|
|
|
13869
14228
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
13870
14229
|
}
|
|
13871
14230
|
}
|
|
14231
|
+
} // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
|
|
14232
|
+
// Continuity capture is a standalone Claude Code opt-in — shown whenever
|
|
14233
|
+
// Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
|
|
14234
|
+
if (claudeCodeDetected) {
|
|
13872
14235
|
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
13873
14236
|
// the SessionStart check above: installed / absent / stale-form).
|
|
13874
14237
|
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
@@ -13926,7 +14289,7 @@ program
|
|
|
13926
14289
|
// command Claude Code uses, written to ~/.codex/hooks.json. Continuity
|
|
13927
14290
|
// and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
|
|
13928
14291
|
// is the hook file.
|
|
13929
|
-
if (
|
|
14292
|
+
if (codexConfigured) {
|
|
13930
14293
|
const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
|
|
13931
14294
|
if (hook.present) {
|
|
13932
14295
|
if (hook.execution === "broken") {
|
|
@@ -13952,6 +14315,28 @@ program
|
|
|
13952
14315
|
else {
|
|
13953
14316
|
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
14317
|
}
|
|
14318
|
+
// flair#1516: same version-skew check as Claude Code — a Codex hook
|
|
14319
|
+
// left behind by an upgrade keeps launching the OLD adapter while
|
|
14320
|
+
// the Codex MCP block advertises the new pin.
|
|
14321
|
+
const codexHookSkew = checkSessionStartHookPinSkew(homedir(), "codex");
|
|
14322
|
+
if (codexHookSkew.skewed) {
|
|
14323
|
+
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`);
|
|
14324
|
+
if (autoFix) {
|
|
14325
|
+
if (dryRun) {
|
|
14326
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
14327
|
+
}
|
|
14328
|
+
else {
|
|
14329
|
+
const repin = repinSessionStartHook(homedir(), "codex");
|
|
14330
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
14331
|
+
if (repin.ok && repin.action === "update")
|
|
14332
|
+
fixed++;
|
|
14333
|
+
}
|
|
14334
|
+
}
|
|
14335
|
+
else {
|
|
14336
|
+
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)")}`);
|
|
14337
|
+
}
|
|
14338
|
+
issues++;
|
|
14339
|
+
}
|
|
13955
14340
|
if (!hook.silenced && hook.ours) {
|
|
13956
14341
|
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
14342
|
if (hook.upgradable) {
|
|
@@ -14006,7 +14391,7 @@ program
|
|
|
14006
14391
|
// Catalog is the install-health verdict — count fail/unrun here, not
|
|
14007
14392
|
// via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
|
|
14008
14393
|
// --fix that cleared a catalog member shows up in the found→fixed delta.
|
|
14009
|
-
const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
|
|
14394
|
+
const catalogAfter = autoFix ? runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds }) : catalogBefore;
|
|
14010
14395
|
const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
|
|
14011
14396
|
issues += catalogDelta.found;
|
|
14012
14397
|
if (autoFix)
|
|
@@ -14015,6 +14400,74 @@ program
|
|
|
14015
14400
|
for (const row of renderCatalogDoctorLines(catalogAfter)) {
|
|
14016
14401
|
console.log(` ${render.icons[row.icon]} ${row.line}`);
|
|
14017
14402
|
}
|
|
14403
|
+
// 7b. Launchd management repair (flair#1573 slice b) — `doctor --fix`
|
|
14404
|
+
// repairs a MISSING, CORRUPT, or DETACHED launchd plist. This is a
|
|
14405
|
+
// distinct concern from the install-health catalog above (which
|
|
14406
|
+
// `upgrade` also asserts), so it owns its own reporting + counting
|
|
14407
|
+
// rather than double-counting the catalog's launchd check. The
|
|
14408
|
+
// DECISION is pure (planLaunchdRepairFor -> planLaunchdRepair); the
|
|
14409
|
+
// EXECUTION (adopt: clean-stop -> regenerate pass-file plist -> load ->
|
|
14410
|
+
// verify) is repairLaunchdManagement, which is the only place that
|
|
14411
|
+
// touches the real filesystem and launchctl.
|
|
14412
|
+
console.log(`\n ${render.wrap(render.c.bold, "Launchd management")}`);
|
|
14413
|
+
if (autoFix && !dryRun) {
|
|
14414
|
+
// Execute the repair directly; it re-derives the plan internally and
|
|
14415
|
+
// verifies via assessLaunchdManagement (fail-loud, never a silent pass).
|
|
14416
|
+
const repairResult = await repairLaunchdManagement(defaultDataDir(), effectivePort);
|
|
14417
|
+
switch (repairResult.kind) {
|
|
14418
|
+
case "no-op":
|
|
14419
|
+
console.log(` ${render.icons.ok} ${repairResult.detail}`);
|
|
14420
|
+
break;
|
|
14421
|
+
case "refused":
|
|
14422
|
+
issues++;
|
|
14423
|
+
console.log(` ${render.icons.error} ${repairResult.detail}`);
|
|
14424
|
+
break;
|
|
14425
|
+
case "repaired":
|
|
14426
|
+
fixed++;
|
|
14427
|
+
console.log(` ${render.icons.ok} ${repairResult.detail}`);
|
|
14428
|
+
break;
|
|
14429
|
+
case "failed":
|
|
14430
|
+
issues++;
|
|
14431
|
+
console.log(` ${render.icons.error} ${repairResult.detail}`);
|
|
14432
|
+
if (repairResult.remedy)
|
|
14433
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${repairResult.remedy.join(" && ")}`);
|
|
14434
|
+
break;
|
|
14435
|
+
}
|
|
14436
|
+
}
|
|
14437
|
+
else {
|
|
14438
|
+
// Report only (no --fix, or --fix --dry-run): compute the plan, touch
|
|
14439
|
+
// nothing. A regenerate plan is drift; a refuse plan is a named refusal.
|
|
14440
|
+
const repairPlan = planLaunchdRepairFor(defaultDataDir(), effectivePort);
|
|
14441
|
+
switch (repairPlan.plan.kind) {
|
|
14442
|
+
case "no-op":
|
|
14443
|
+
console.log(` ${render.icons.ok} ${repairPlan.plan.detail}`);
|
|
14444
|
+
break;
|
|
14445
|
+
case "refuse":
|
|
14446
|
+
issues++;
|
|
14447
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
14448
|
+
break;
|
|
14449
|
+
case "regenerate":
|
|
14450
|
+
issues++;
|
|
14451
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
14452
|
+
if (dryRun) {
|
|
14453
|
+
console.log(` ${render.wrap(render.c.dim, "Would regenerate")} the launchd plist (pass-file mode) and load it`);
|
|
14454
|
+
}
|
|
14455
|
+
else {
|
|
14456
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(regenerates the plist in pass-file mode, loads it, and verifies)")}`);
|
|
14457
|
+
}
|
|
14458
|
+
break;
|
|
14459
|
+
case "adopt":
|
|
14460
|
+
issues++;
|
|
14461
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
14462
|
+
if (dryRun) {
|
|
14463
|
+
console.log(` ${render.wrap(render.c.dim, "Would adopt")} the direct-spawned instance into launchd (clean-stop, regenerate, load — bounces the live instance)`);
|
|
14464
|
+
}
|
|
14465
|
+
else {
|
|
14466
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(clean-stops the direct process, regenerates the plist, loads it, and verifies — bounces the live instance)")}`);
|
|
14467
|
+
}
|
|
14468
|
+
break;
|
|
14469
|
+
}
|
|
14470
|
+
}
|
|
14018
14471
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
14019
14472
|
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
14020
14473
|
// sections required --agent explicitly; doctor already enumerates every
|
|
@@ -14484,11 +14937,13 @@ export const QUALITY_RECALL_K = 5;
|
|
|
14484
14937
|
* pulls embedding vectors inline — the defect in flair#1360 was an
|
|
14485
14938
|
* unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
|
|
14486
14939
|
* vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
|
|
14487
|
-
* 10 memories. `
|
|
14488
|
-
*
|
|
14489
|
-
*
|
|
14940
|
+
* 10 memories. `archived` is projected so the planner can drop basemented
|
|
14941
|
+
* rows before sampling (flair#857 — SemanticSearch excludes them, so an
|
|
14942
|
+
* archived row in the sample is a guaranteed miss). `type` is intentionally
|
|
14943
|
+
* omitted: it is not a declared Memory column (see schemas/memory.graphql);
|
|
14944
|
+
* snapshot exclusion keys off `subject` (`quality-snapshot/…`).
|
|
14490
14945
|
*/
|
|
14491
|
-
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
|
|
14946
|
+
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt", "archived"];
|
|
14492
14947
|
/**
|
|
14493
14948
|
* Extra most-recent rows fetched beyond `sampleSize` so
|
|
14494
14949
|
* `planRecallSpotCheck` can drop the sweep's own quality-snapshot
|
|
@@ -14635,20 +15090,26 @@ function isQualitySnapshotRow(m) {
|
|
|
14635
15090
|
* fail-closed health rules are testable without any I/O (flair#967).
|
|
14636
15091
|
*
|
|
14637
15092
|
* Order of operations, and why:
|
|
14638
|
-
* 1. drop
|
|
15093
|
+
* 1. drop archived rows (SemanticSearch excludes them — flair#857 — so a
|
|
15094
|
+
* basemented row in the sample is a guaranteed miss, not a recall signal);
|
|
15095
|
+
* 2. drop the tool's own quality-snapshot rows (never grade your own
|
|
14639
15096
|
* bookkeeping);
|
|
14640
|
-
*
|
|
15097
|
+
* 3. take the `sampleSize` most-recently-written remaining rows (unchanged —
|
|
14641
15098
|
* recency is still the sampling frame; see the issue's direction 3 for the
|
|
14642
15099
|
* stratified-sampling follow-up this deliberately does NOT take on);
|
|
14643
|
-
*
|
|
14644
|
-
*
|
|
15100
|
+
* 4. derive each cue via deriveRecallCue;
|
|
15101
|
+
* 5. judge the window: any duplicate cue, or any empty cue, makes it
|
|
14645
15102
|
* UNSCORABLE — reported as unhealthy, never silently scored.
|
|
14646
15103
|
*/
|
|
14647
15104
|
export function planRecallSpotCheck(memories, opts = {}) {
|
|
14648
15105
|
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
14649
15106
|
const rows = Array.isArray(memories) ? memories : [];
|
|
14650
|
-
|
|
14651
|
-
|
|
15107
|
+
// `archived !== true` matches SemanticSearch / AdminMemory: unset and
|
|
15108
|
+
// false stay in the live pool; only an explicit basement is dropped.
|
|
15109
|
+
const live = rows.filter((m) => m?.archived !== true);
|
|
15110
|
+
const scorable = live.filter((m) => !isQualitySnapshotRow(m ?? {}));
|
|
15111
|
+
const excludedArchivedRows = rows.length - live.length;
|
|
15112
|
+
const excludedSnapshotRows = live.length - scorable.length;
|
|
14652
15113
|
const sorted = scorable.slice().sort((a, b) => {
|
|
14653
15114
|
const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
14654
15115
|
const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
@@ -14666,7 +15127,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
|
|
|
14666
15127
|
}
|
|
14667
15128
|
const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
|
|
14668
15129
|
if (duplicateCues.length === 0 && emptyCueCount === 0) {
|
|
14669
|
-
return { sampled, health: { healthy: true }, excludedSnapshotRows };
|
|
15130
|
+
return { sampled, health: { healthy: true }, excludedSnapshotRows, excludedArchivedRows };
|
|
14670
15131
|
}
|
|
14671
15132
|
const parts = [];
|
|
14672
15133
|
if (duplicateCues.length > 0) {
|
|
@@ -14686,6 +15147,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
|
|
|
14686
15147
|
emptyCueCount,
|
|
14687
15148
|
},
|
|
14688
15149
|
excludedSnapshotRows,
|
|
15150
|
+
excludedArchivedRows,
|
|
14689
15151
|
};
|
|
14690
15152
|
}
|
|
14691
15153
|
/**
|
|
@@ -14910,13 +15372,21 @@ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
14910
15372
|
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
14911
15373
|
}
|
|
14912
15374
|
// 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
|
-
//
|
|
15375
|
+
// pure (planRecallSpotCheck above). Archived rows (flair#857) and snapshot
|
|
15376
|
+
// rows are excluded there, so the "enough memories" check has to run on the
|
|
15377
|
+
// PLANNED window, not on the raw row count — an instance whose recent writes
|
|
15378
|
+
// are mostly basemented or the sweep's own bookkeeping should skip with a
|
|
15379
|
+
// reason, not score a short window.
|
|
14917
15380
|
const plan = planRecallSpotCheck(all, { sampleSize });
|
|
14918
15381
|
if (plan.sampled.length < sampleSize) {
|
|
14919
|
-
const
|
|
15382
|
+
const exclusionParts = [];
|
|
15383
|
+
if (plan.excludedArchivedRows > 0) {
|
|
15384
|
+
exclusionParts.push(`${plan.excludedArchivedRows} archived row(s) excluded — SemanticSearch cannot return basemented memories; restore with \`flair memory restore <id>\` if they should be live`);
|
|
15385
|
+
}
|
|
15386
|
+
if (plan.excludedSnapshotRows > 0) {
|
|
15387
|
+
exclusionParts.push(`${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping`);
|
|
15388
|
+
}
|
|
15389
|
+
const excluded = exclusionParts.length > 0 ? ` (${exclusionParts.join("; ")})` : "";
|
|
14920
15390
|
return {
|
|
14921
15391
|
ok: false,
|
|
14922
15392
|
agentId,
|
|
@@ -15198,7 +15668,7 @@ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, op
|
|
|
15198
15668
|
* `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
|
|
15199
15669
|
* Throws on a write failure — the CLI action below is responsible for
|
|
15200
15670
|
* surfacing that as a clear error, same as every other write path here. */
|
|
15201
|
-
async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
15671
|
+
async function storeQualitySnapshot(agentId, agentIdSource, baseUrl, subject, snapshot) {
|
|
15202
15672
|
const memId = `${agentId}-quality-snapshot-${Date.now()}`;
|
|
15203
15673
|
const body = {
|
|
15204
15674
|
id: memId,
|
|
@@ -15210,7 +15680,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
|
15210
15680
|
type: "quality-snapshot",
|
|
15211
15681
|
createdAt: new Date().toISOString(),
|
|
15212
15682
|
};
|
|
15213
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
|
|
15683
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId, agentIdSource });
|
|
15214
15684
|
if (out?.error)
|
|
15215
15685
|
throw new Error(String(out.error));
|
|
15216
15686
|
return memId;
|
|
@@ -15226,8 +15696,8 @@ program
|
|
|
15226
15696
|
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
15227
15697
|
.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
15698
|
.action(async (opts) => {
|
|
15229
|
-
const agentId = resolveSigningAgentId(opts, "quality");
|
|
15230
|
-
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
|
|
15699
|
+
const { agentId, source } = resolveSigningAgentId(opts, "quality");
|
|
15700
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId, source);
|
|
15231
15701
|
if (opts.emit && !agentId) {
|
|
15232
15702
|
console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
|
|
15233
15703
|
process.exit(1);
|
|
@@ -15270,7 +15740,7 @@ program
|
|
|
15270
15740
|
}
|
|
15271
15741
|
}
|
|
15272
15742
|
try {
|
|
15273
|
-
emitResult.snapshotId = await storeQualitySnapshot(agentId, baseUrl, subject, current);
|
|
15743
|
+
emitResult.snapshotId = await storeQualitySnapshot(agentId, source, baseUrl, subject, current);
|
|
15274
15744
|
}
|
|
15275
15745
|
catch (err) {
|
|
15276
15746
|
emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
|
|
@@ -15651,7 +16121,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
|
|
|
15651
16121
|
process.exit(1);
|
|
15652
16122
|
}
|
|
15653
16123
|
applyAdminPassFile(opts);
|
|
15654
|
-
const agentId = resolveSigningAgentId(opts, "memory add");
|
|
16124
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory add");
|
|
15655
16125
|
if (!agentId) {
|
|
15656
16126
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15657
16127
|
process.exit(2);
|
|
@@ -15693,6 +16163,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
|
|
|
15693
16163
|
}
|
|
15694
16164
|
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15695
16165
|
agentId,
|
|
16166
|
+
agentIdSource: source,
|
|
15696
16167
|
explicitAdminPass: opts.adminPass,
|
|
15697
16168
|
adminUser: opts.adminUser,
|
|
15698
16169
|
});
|
|
@@ -15750,7 +16221,7 @@ memory.command("write-task-summary")
|
|
|
15750
16221
|
lines.push(opts.summary);
|
|
15751
16222
|
}
|
|
15752
16223
|
const content = lines.join("\n");
|
|
15753
|
-
const agentId = resolveSigningAgentId(opts, "memory write-task-summary")
|
|
16224
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory write-task-summary");
|
|
15754
16225
|
const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
|
|
15755
16226
|
const body = {
|
|
15756
16227
|
id: memId,
|
|
@@ -15767,7 +16238,7 @@ memory.command("write-task-summary")
|
|
|
15767
16238
|
if (opts.derivedFrom) {
|
|
15768
16239
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
15769
16240
|
}
|
|
15770
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
|
|
16241
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId, agentIdSource: source });
|
|
15771
16242
|
if (out?.error) {
|
|
15772
16243
|
console.error(`Error writing task summary: ${out.error}`);
|
|
15773
16244
|
process.exit(1);
|
|
@@ -15779,6 +16250,7 @@ memory.command("write-task-summary")
|
|
|
15779
16250
|
memory.command("search [query]")
|
|
15780
16251
|
.description("Semantic search over an agent's memories (query via positional arg or --q)")
|
|
15781
16252
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
16253
|
+
.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
16254
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
15783
16255
|
.option("--limit <n>", "Max results", "5")
|
|
15784
16256
|
.option("--tag <tag>")
|
|
@@ -15787,7 +16259,7 @@ memory.command("search [query]")
|
|
|
15787
16259
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15788
16260
|
.option("--port <port>", "Harper HTTP port")
|
|
15789
16261
|
.action(async (queryArg, opts) => {
|
|
15790
|
-
const agentId = resolveSigningAgentId(opts, "memory search");
|
|
16262
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory search");
|
|
15791
16263
|
if (!agentId) {
|
|
15792
16264
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15793
16265
|
process.exit(2);
|
|
@@ -15803,7 +16275,7 @@ memory.command("search [query]")
|
|
|
15803
16275
|
if (opts.includeArchived)
|
|
15804
16276
|
body.includeArchived = true;
|
|
15805
16277
|
const baseUrl = resolveBaseUrl(opts);
|
|
15806
|
-
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
16278
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId, agentIdSource: source, explicitAdminPass: opts.adminPass });
|
|
15807
16279
|
console.log(JSON.stringify(res, null, 2));
|
|
15808
16280
|
});
|
|
15809
16281
|
// ─── flair memory basement / restore ────────────────────────────────────────
|
|
@@ -15820,13 +16292,13 @@ memory.command("basement <id>")
|
|
|
15820
16292
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15821
16293
|
.option("--port <port>", "Harper HTTP port")
|
|
15822
16294
|
.action(async (id, opts) => {
|
|
15823
|
-
const agentId = resolveSigningAgentId(opts, "memory basement");
|
|
16295
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory basement");
|
|
15824
16296
|
if (!agentId) {
|
|
15825
16297
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15826
16298
|
process.exit(2);
|
|
15827
16299
|
}
|
|
15828
16300
|
const baseUrl = resolveBaseUrl(opts);
|
|
15829
|
-
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
|
|
16301
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId, agentIdSource: source });
|
|
15830
16302
|
console.log(JSON.stringify(res, null, 2));
|
|
15831
16303
|
});
|
|
15832
16304
|
memory.command("restore <id>")
|
|
@@ -15836,13 +16308,13 @@ memory.command("restore <id>")
|
|
|
15836
16308
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15837
16309
|
.option("--port <port>", "Harper HTTP port")
|
|
15838
16310
|
.action(async (id, opts) => {
|
|
15839
|
-
const agentId = resolveSigningAgentId(opts, "memory restore");
|
|
16311
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory restore");
|
|
15840
16312
|
if (!agentId) {
|
|
15841
16313
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15842
16314
|
process.exit(2);
|
|
15843
16315
|
}
|
|
15844
16316
|
const baseUrl = resolveBaseUrl(opts);
|
|
15845
|
-
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
|
|
16317
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId, agentIdSource: source });
|
|
15846
16318
|
console.log(JSON.stringify(res, null, 2));
|
|
15847
16319
|
});
|
|
15848
16320
|
memory.command("list")
|
|
@@ -15853,13 +16325,13 @@ memory.command("list")
|
|
|
15853
16325
|
.option("--limit <n>", "Max rows when using --hash-fallback", "50")
|
|
15854
16326
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
15855
16327
|
.action(async (opts) => {
|
|
15856
|
-
const agentId = resolveSigningAgentId(opts, "memory list");
|
|
16328
|
+
const { agentId, source } = resolveSigningAgentId(opts, "memory list");
|
|
15857
16329
|
if (!agentId) {
|
|
15858
16330
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
15859
16331
|
process.exit(2);
|
|
15860
16332
|
}
|
|
15861
16333
|
const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
|
|
15862
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
|
|
16334
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { agentId, agentIdSource: source });
|
|
15863
16335
|
const mode = render.resolveOutputMode(opts);
|
|
15864
16336
|
// hashFallback flag changes the lens: instead of all memories, show
|
|
15865
16337
|
// only those that need re-embedding. Keep that surface separate.
|
|
@@ -16191,7 +16663,7 @@ program
|
|
|
16191
16663
|
.option("--json", "Output raw JSON array")
|
|
16192
16664
|
.action(async (query, opts) => {
|
|
16193
16665
|
try {
|
|
16194
|
-
const agentId = resolveSigningAgentId(opts, "search");
|
|
16666
|
+
const { agentId } = resolveSigningAgentId(opts, "search");
|
|
16195
16667
|
if (!agentId) {
|
|
16196
16668
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
16197
16669
|
process.exit(2);
|
|
@@ -16351,7 +16823,7 @@ program
|
|
|
16351
16823
|
.option("--key <path>", "Ed25519 private key path")
|
|
16352
16824
|
.option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
|
|
16353
16825
|
.action(async (opts) => {
|
|
16354
|
-
const agentId = resolveSigningAgentId(opts, "bootstrap");
|
|
16826
|
+
const { agentId, source } = resolveSigningAgentId(opts, "bootstrap");
|
|
16355
16827
|
if (!agentId) {
|
|
16356
16828
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
16357
16829
|
process.exit(2);
|
|
@@ -16366,7 +16838,7 @@ program
|
|
|
16366
16838
|
// finds a usable key. Previously this only ever tried Ed25519 (no
|
|
16367
16839
|
// admin fallback at all) and sent NO Authorization header when no key
|
|
16368
16840
|
// 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 }));
|
|
16841
|
+
const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, agentIdSource: source, explicitKeyPath: opts.key }));
|
|
16370
16842
|
if (mode === "json") {
|
|
16371
16843
|
// Agent-first: emit the full server response, augmented with the cap
|
|
16372
16844
|
// that was requested. Includes context, sections, tokenEstimate, etc.
|
|
@@ -16446,7 +16918,7 @@ relationship.command("add")
|
|
|
16446
16918
|
.option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
|
|
16447
16919
|
.option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
|
|
16448
16920
|
.action(async (opts) => {
|
|
16449
|
-
const agentId = resolveSigningAgentId(opts, "relationship add")
|
|
16921
|
+
const { agentId, source } = resolveSigningAgentId(opts, "relationship add");
|
|
16450
16922
|
const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
|
|
16451
16923
|
const body = {
|
|
16452
16924
|
id,
|
|
@@ -16463,11 +16935,11 @@ relationship.command("add")
|
|
|
16463
16935
|
body.validTo = opts.validTo;
|
|
16464
16936
|
if (opts.source)
|
|
16465
16937
|
body.source = opts.source;
|
|
16466
|
-
const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
|
|
16938
|
+
const out = await api("PUT", `/Relationship/${id}`, body, { agentId, agentIdSource: source });
|
|
16467
16939
|
console.log(JSON.stringify(out, null, 2));
|
|
16468
16940
|
});
|
|
16469
16941
|
const soul = program.command("soul").description("Manage agent soul entries");
|
|
16470
|
-
soul.command("set")
|
|
16942
|
+
addSharedCredentialOptions(soul.command("set"))
|
|
16471
16943
|
.description("Set (upsert) a soul entry for an agent by key")
|
|
16472
16944
|
.requiredOption("--agent <id>")
|
|
16473
16945
|
.requiredOption("--key <key>")
|
|
@@ -16475,6 +16947,7 @@ soul.command("set")
|
|
|
16475
16947
|
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
|
|
16476
16948
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
16477
16949
|
.action(async (opts) => {
|
|
16950
|
+
applyAdminPassFile(opts);
|
|
16478
16951
|
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
16479
16952
|
// The Soul table resource has no POST handler, so a collection POST /Soul
|
|
16480
16953
|
// 405s; the record must be written by its primary key. (#498)
|
|
@@ -16484,7 +16957,7 @@ soul.command("set")
|
|
|
16484
16957
|
// precedence — but before this, api() re-derived the signer as
|
|
16485
16958
|
// FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
|
|
16486
16959
|
// wrote a record owned by X while signing as Y (the soul family's stale rung).
|
|
16487
|
-
const agentId = resolveSigningAgentId(opts, "soul set")
|
|
16960
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul set");
|
|
16488
16961
|
const id = `${opts.agent}:${opts.key}`;
|
|
16489
16962
|
const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
|
|
16490
16963
|
id,
|
|
@@ -16493,7 +16966,7 @@ soul.command("set")
|
|
|
16493
16966
|
value: opts.value,
|
|
16494
16967
|
durability: opts.durability,
|
|
16495
16968
|
createdAt: new Date().toISOString(),
|
|
16496
|
-
}, { agentId });
|
|
16969
|
+
}, { agentId, agentIdSource: source, explicitAdminPass: opts.adminPass, adminUser: opts.adminUser });
|
|
16497
16970
|
const mode = render.resolveOutputMode(opts);
|
|
16498
16971
|
if (mode === "json") {
|
|
16499
16972
|
console.log(render.asJSON(out));
|
|
@@ -16515,8 +16988,8 @@ soul.command("get")
|
|
|
16515
16988
|
// flair#1183: /Soul reads are verified (any registered agent). Resolve the
|
|
16516
16989
|
// signer through the canonical seam so soul get honors the SAME precedence
|
|
16517
16990
|
// 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 });
|
|
16991
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul get");
|
|
16992
|
+
const out = await api("GET", `/Soul/${id}`, undefined, { agentId, agentIdSource: source });
|
|
16520
16993
|
const mode = render.resolveOutputMode(opts);
|
|
16521
16994
|
if (mode === "json") {
|
|
16522
16995
|
console.log(render.asJSON(out));
|
|
@@ -16548,12 +17021,12 @@ soul.command("list")
|
|
|
16548
17021
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
16549
17022
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
16550
17023
|
.action(async (opts) => {
|
|
16551
|
-
const agentId = resolveSigningAgentId(opts, "soul list");
|
|
17024
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul list");
|
|
16552
17025
|
if (!agentId) {
|
|
16553
17026
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
16554
17027
|
process.exit(2);
|
|
16555
17028
|
}
|
|
16556
|
-
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
|
|
17029
|
+
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId, agentIdSource: source });
|
|
16557
17030
|
const mode = render.resolveOutputMode(opts);
|
|
16558
17031
|
if (mode === "json") {
|
|
16559
17032
|
console.log(render.asJSON(out));
|
|
@@ -17379,19 +17852,7 @@ program
|
|
|
17379
17852
|
console.warn(` warn: agent ${agent.id}: ${err.message}`);
|
|
17380
17853
|
}
|
|
17381
17854
|
}
|
|
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
|
|
17855
|
+
// Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
|
|
17395
17856
|
console.log("Restoring souls...");
|
|
17396
17857
|
let soulCount = 0;
|
|
17397
17858
|
for (const soul of souls) {
|
|
@@ -17403,6 +17864,17 @@ program
|
|
|
17403
17864
|
console.warn(` warn: soul ${soul.id}: ${err.message}`);
|
|
17404
17865
|
}
|
|
17405
17866
|
}
|
|
17867
|
+
console.log("Restoring memories...");
|
|
17868
|
+
let memoryCount = 0;
|
|
17869
|
+
for (const memory of memories) {
|
|
17870
|
+
try {
|
|
17871
|
+
await adminPut(`/Memory/${memory.id}`, memory);
|
|
17872
|
+
memoryCount++;
|
|
17873
|
+
}
|
|
17874
|
+
catch (err) {
|
|
17875
|
+
console.warn(` warn: memory ${memory.id}: ${err.message}`);
|
|
17876
|
+
}
|
|
17877
|
+
}
|
|
17406
17878
|
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
|
|
17407
17879
|
console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
|
|
17408
17880
|
console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
|
|
@@ -17574,30 +18046,29 @@ program
|
|
|
17574
18046
|
console.log(typeof seedOpsTarget === "string"
|
|
17575
18047
|
? ` Agent registered (ops: ${seedOpsTarget})`
|
|
17576
18048
|
: ` Agent registered`);
|
|
17577
|
-
// Restore memories
|
|
18049
|
+
// Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
|
|
17578
18050
|
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
17579
|
-
let
|
|
17580
|
-
for (const
|
|
18051
|
+
let soulCount = 0;
|
|
18052
|
+
for (const soul of data.souls ?? []) {
|
|
17581
18053
|
try {
|
|
17582
|
-
await fetch(`${baseUrl}/
|
|
18054
|
+
await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
|
|
17583
18055
|
method: "PUT",
|
|
17584
18056
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
17585
|
-
body: JSON.stringify(
|
|
18057
|
+
body: JSON.stringify(soul),
|
|
17586
18058
|
});
|
|
17587
|
-
|
|
18059
|
+
soulCount++;
|
|
17588
18060
|
}
|
|
17589
18061
|
catch { /* skip failures */ }
|
|
17590
18062
|
}
|
|
17591
|
-
|
|
17592
|
-
|
|
17593
|
-
for (const soul of data.souls ?? []) {
|
|
18063
|
+
let memCount = 0;
|
|
18064
|
+
for (const mem of data.memories ?? []) {
|
|
17594
18065
|
try {
|
|
17595
|
-
await fetch(`${baseUrl}/
|
|
18066
|
+
await fetch(`${baseUrl}/Memory/${mem.id}`, {
|
|
17596
18067
|
method: "PUT",
|
|
17597
18068
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
17598
|
-
body: JSON.stringify(
|
|
18069
|
+
body: JSON.stringify(mem),
|
|
17599
18070
|
});
|
|
17600
|
-
|
|
18071
|
+
memCount++;
|
|
17601
18072
|
}
|
|
17602
18073
|
catch { /* skip failures */ }
|
|
17603
18074
|
}
|
|
@@ -17914,7 +18385,7 @@ presence
|
|
|
17914
18385
|
.option("--port <port>", "Harper HTTP port")
|
|
17915
18386
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
17916
18387
|
.action(async (opts) => {
|
|
17917
|
-
const agentId = resolveSigningAgentId(opts, "presence set");
|
|
18388
|
+
const { agentId } = resolveSigningAgentId(opts, "presence set");
|
|
17918
18389
|
if (!agentId) {
|
|
17919
18390
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
17920
18391
|
process.exit(1);
|
|
@@ -18000,7 +18471,7 @@ workspace
|
|
|
18000
18471
|
.option("--port <port>", "Harper HTTP port")
|
|
18001
18472
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
18002
18473
|
.action(async (opts) => {
|
|
18003
|
-
const agentId = resolveSigningAgentId(opts, "workspace set");
|
|
18474
|
+
const { agentId } = resolveSigningAgentId(opts, "workspace set");
|
|
18004
18475
|
if (!agentId) {
|
|
18005
18476
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
18006
18477
|
process.exit(1);
|
|
@@ -18150,7 +18621,7 @@ program
|
|
|
18150
18621
|
.option("--port <port>", "Harper HTTP port")
|
|
18151
18622
|
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
18152
18623
|
.action(async (opts) => {
|
|
18153
|
-
const agentId = resolveSigningAgentId(opts, "orgevent");
|
|
18624
|
+
const { agentId } = resolveSigningAgentId(opts, "orgevent");
|
|
18154
18625
|
if (!agentId) {
|
|
18155
18626
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
18156
18627
|
process.exit(1);
|
|
@@ -18220,7 +18691,7 @@ program
|
|
|
18220
18691
|
.option("--json", "Output raw JSON")
|
|
18221
18692
|
.action(async (entity, opts) => {
|
|
18222
18693
|
try {
|
|
18223
|
-
const agentId = resolveSigningAgentId(opts, "attention");
|
|
18694
|
+
const { agentId } = resolveSigningAgentId(opts, "attention");
|
|
18224
18695
|
if (!agentId) {
|
|
18225
18696
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
18226
18697
|
process.exit(2);
|