@tpsdev-ai/flair 0.51.0 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli.js +443 -133
- package/dist/lib/daemon-liveness.js +207 -0
- package/dist/lib/upgrade-migrations.js +181 -0
- package/dist/resources/MemoryArchive.js +94 -0
- package/dist/resources/MemoryBootstrap.js +3 -1
- package/dist/resources/SemanticSearch.js +7 -2
- package/dist/resources/mcp-tools.js +106 -1
- package/dist/resources/record-types.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-liveness.ts — the five-state liveness machine for a local Flair
|
|
3
|
+
* (Harper) daemon (flair#1454).
|
|
4
|
+
*
|
|
5
|
+
* The defect this replaces: `flair stop` decided "not running" from a single
|
|
6
|
+
* `lsof -ti :<port>` whose `catch` converted "lsof unavailable" into "nothing
|
|
7
|
+
* is listening" — absence rendered as a definite negative. `flair start` then
|
|
8
|
+
* "succeeded" over the live daemon it had just failed to see, and a
|
|
9
|
+
* `stop; start` wrapper (a systemd `Type=forking` unit's `ExecStop`, say)
|
|
10
|
+
* produced a second daemon while the first kept running.
|
|
11
|
+
*
|
|
12
|
+
* The fix is a classifier with FIVE states, not a boolean:
|
|
13
|
+
*
|
|
14
|
+
* RUNNING identity-verified pid alive + health 200
|
|
15
|
+
* NOT_RUNNING no pidfile, or verified dead + port refused
|
|
16
|
+
* WEDGED identity-VERIFIED pid alive + not serving -> stop ACTS
|
|
17
|
+
* DISAGREEMENT evidence conflicts, identity NOT verified -> stop REFUSES
|
|
18
|
+
* UNKNOWN insufficient evidence to classify -> stop REFUSES
|
|
19
|
+
*
|
|
20
|
+
* THE INVARIANT (the whole design): WEDGED is reachable ONLY when the pidfile
|
|
21
|
+
* identity has been VERIFIED. The same physical evidence with an unverified
|
|
22
|
+
* identity lands in DISAGREEMENT. This is encoded structurally — the classifier
|
|
23
|
+
* takes the identity-check RESULT as an input, and there is no code path to
|
|
24
|
+
* WEDGED that bypasses it. Killing a wedged daemon is recovery only when we
|
|
25
|
+
* have proven the pid is ours; without that proof it is a recycled-PID gamble
|
|
26
|
+
* and the machine refuses.
|
|
27
|
+
*
|
|
28
|
+
* Identity is carried by a sidecar (`<dataDir>/flair-daemon.json`) that flair
|
|
29
|
+
* writes at spawn — `{ pid, startTimeMs, port, flairVersion }` — because
|
|
30
|
+
* `hdb.pid` is written by the Harper process itself and flair does not own its
|
|
31
|
+
* format. Verification = hdb.pid's pid matches the sidecar pid AND the live
|
|
32
|
+
* process's start time matches the sidecar's startTimeMs (±2s).
|
|
33
|
+
*
|
|
34
|
+
* This module is PURE: it classifies and parses, and never touches the
|
|
35
|
+
* filesystem, the network, or a process. The adapters that do (O_NOFOLLOW
|
|
36
|
+
* reads, `kill(pid, 0)`, the health probe, the start-time readers) live in
|
|
37
|
+
* `src/cli.ts`, so every branch here is unit-testable without a daemon.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Classify the gathered evidence into one of the five states.
|
|
41
|
+
*
|
|
42
|
+
* The WEDGED gate is the first branch and the only one that can return WEDGED:
|
|
43
|
+
* it requires `identity.kind === "verified"` AND `pidLiveness.kind === "alive"`.
|
|
44
|
+
* Every other "alive" pid — unverified identity, or EPERM — is DISAGREEMENT.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyDaemonState(ev, ctx) {
|
|
47
|
+
if (ev.dataDirUnsafe !== null) {
|
|
48
|
+
return { state: "UNKNOWN", detail: ev.dataDirUnsafe };
|
|
49
|
+
}
|
|
50
|
+
if (ev.pidfile.kind === "unreadable") {
|
|
51
|
+
return { state: "UNKNOWN", detail: ev.pidfile.reason };
|
|
52
|
+
}
|
|
53
|
+
const pid = ev.pidfile.kind === "present" ? ev.pidfile.pid : null;
|
|
54
|
+
const liveness = ev.pidLiveness;
|
|
55
|
+
// THE INVARIANT: WEDGED is reachable only through a VERIFIED identity.
|
|
56
|
+
if (ev.identity.kind === "verified" && liveness?.kind === "alive") {
|
|
57
|
+
if (ev.health.kind === "ok") {
|
|
58
|
+
return { state: "RUNNING", pid: ev.identity.pid };
|
|
59
|
+
}
|
|
60
|
+
return { state: "WEDGED", pid: ev.identity.pid };
|
|
61
|
+
}
|
|
62
|
+
// No live pid recorded (absent, or the recorded pid is gone).
|
|
63
|
+
if (pid === null || liveness?.kind === "gone") {
|
|
64
|
+
if (ev.health.kind === "refused") {
|
|
65
|
+
return { state: "NOT_RUNNING" };
|
|
66
|
+
}
|
|
67
|
+
if (ev.health.kind === "ok") {
|
|
68
|
+
return {
|
|
69
|
+
state: "DISAGREEMENT",
|
|
70
|
+
detail: pid === null
|
|
71
|
+
? `a process is serving port ${ctx.port}, but no pid is recorded under ${ctx.dataDir}`
|
|
72
|
+
: `a process is serving port ${ctx.port}, but the recorded pid ${pid} is not alive`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
state: "UNKNOWN",
|
|
77
|
+
detail: `could not determine whether Flair is running: ` +
|
|
78
|
+
`${pid === null ? "no pid is recorded" : `recorded pid ${pid} is not alive`} ` +
|
|
79
|
+
`and the health check on port ${ctx.port} did not respond`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// EPERM — the recorded pid exists but belongs to another user.
|
|
83
|
+
if (liveness?.kind === "eperm") {
|
|
84
|
+
return {
|
|
85
|
+
state: "DISAGREEMENT",
|
|
86
|
+
detail: `the recorded pid ${pid} exists but belongs to another user — refusing to act on it`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Alive, but identity NOT verified — the WEDGED shape without the proof.
|
|
90
|
+
if (liveness?.kind === "alive") {
|
|
91
|
+
const reason = ev.identity.kind === "unverified" ? ev.identity.reason : "no identity sidecar";
|
|
92
|
+
return {
|
|
93
|
+
state: "DISAGREEMENT",
|
|
94
|
+
detail: `the recorded pid ${pid} is alive, but its identity could not be verified (${reason}) — refusing to act on it`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { state: "UNKNOWN", detail: "could not determine whether Flair is running" };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Verify the pidfile identity against the sidecar. Pure — `readStartTime` is
|
|
101
|
+
* injected so the live-process read (the only non-deterministic part) stays in
|
|
102
|
+
* the adapter layer.
|
|
103
|
+
*
|
|
104
|
+
* Verified requires ALL of: a pidfile pid, a readable sidecar, matching pids,
|
|
105
|
+
* a readable live start time, and a start time within `toleranceMs` (±2s).
|
|
106
|
+
* Any shortfall is `unverified` (or `none` when there is nothing to check).
|
|
107
|
+
*/
|
|
108
|
+
export function verifyIdentity(input) {
|
|
109
|
+
const tolerance = input.toleranceMs ?? 2000;
|
|
110
|
+
if (input.pidfilePid === null) {
|
|
111
|
+
return { kind: "none" };
|
|
112
|
+
}
|
|
113
|
+
if (input.sidecar.kind === "absent") {
|
|
114
|
+
return { kind: "none" };
|
|
115
|
+
}
|
|
116
|
+
if (input.sidecar.kind === "unreadable") {
|
|
117
|
+
return { kind: "unverified", reason: input.sidecar.reason };
|
|
118
|
+
}
|
|
119
|
+
if (input.pidfilePid !== input.sidecar.pid) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "unverified",
|
|
122
|
+
reason: `hdb.pid names pid ${input.pidfilePid} but the sidecar records pid ${input.sidecar.pid}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const actual = input.readStartTime(input.pidfilePid);
|
|
126
|
+
if (actual === null) {
|
|
127
|
+
return { kind: "unverified", reason: `could not read the start time of pid ${input.pidfilePid}` };
|
|
128
|
+
}
|
|
129
|
+
if (!isStartTimeMatch(actual, input.sidecar.startTimeMs, tolerance)) {
|
|
130
|
+
return {
|
|
131
|
+
kind: "unverified",
|
|
132
|
+
reason: `pid ${input.pidfilePid} started at ${actual}ms but the sidecar records ${input.sidecar.startTimeMs}ms`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { kind: "verified", pid: input.pidfilePid };
|
|
136
|
+
}
|
|
137
|
+
/** `|actual - recorded| <= tolerance`. */
|
|
138
|
+
export function isStartTimeMatch(actualMs, recordedMs, toleranceMs = 2000) {
|
|
139
|
+
return Math.abs(actualMs - recordedMs) <= toleranceMs;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse `/proc/<pid>/stat` field 22 (starttime, in clock ticks).
|
|
143
|
+
*
|
|
144
|
+
* Field 2 (comm) is parenthesised and may itself contain spaces and `)`
|
|
145
|
+
* characters, so the split is on the LAST `)` — not the first space, which is
|
|
146
|
+
* the classic bug that mangles any process whose comm has a space in it.
|
|
147
|
+
* Returns the raw tick count; the ticks→epoch conversion (which needs boot
|
|
148
|
+
* time and CLK_TCK) lives in the adapter.
|
|
149
|
+
*/
|
|
150
|
+
export function parseProcStatStartTime(stat) {
|
|
151
|
+
const closeParen = stat.lastIndexOf(")");
|
|
152
|
+
if (closeParen < 0)
|
|
153
|
+
return null;
|
|
154
|
+
const rest = stat.slice(closeParen + 1).trim().split(/\s+/);
|
|
155
|
+
// rest[0] is field 3 (state); field 22 (starttime) is therefore rest[19].
|
|
156
|
+
const starttime = Number(rest[19]);
|
|
157
|
+
return Number.isFinite(starttime) ? starttime : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Convert a `/proc/<pid>/stat` starttime (clock ticks since boot) to epoch ms,
|
|
161
|
+
* given the system uptime in seconds and the current wall clock. Pure so the
|
|
162
|
+
* arithmetic is testable without a live process.
|
|
163
|
+
*/
|
|
164
|
+
export function procStartTimeToEpochMs(starttimeTicks, uptimeSeconds, nowMs, clkTck = 100) {
|
|
165
|
+
const bootTimeMs = nowMs - uptimeSeconds * 1000;
|
|
166
|
+
return bootTimeMs + (starttimeTicks / clkTck) * 1000;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Parse `ps -o lstart= -p <pid>` output ("Sat Aug 29 15:03:22 2026") to epoch
|
|
170
|
+
* ms. Returns null when the output is empty or unparseable — the caller treats
|
|
171
|
+
* that as "identity unverified", never as a verdict toward the destructive
|
|
172
|
+
* branch.
|
|
173
|
+
*/
|
|
174
|
+
export function parsePsLstart(output) {
|
|
175
|
+
const trimmed = output.trim();
|
|
176
|
+
if (!trimmed)
|
|
177
|
+
return null;
|
|
178
|
+
const ms = Date.parse(trimmed);
|
|
179
|
+
return Number.isFinite(ms) ? ms : null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Parse the sidecar JSON. Returns null on malformed content or a missing /
|
|
183
|
+
* non-positive pid/startTimeMs/port — the caller reports that as "unreadable".
|
|
184
|
+
*/
|
|
185
|
+
export function parseSidecarJson(content) {
|
|
186
|
+
let obj;
|
|
187
|
+
try {
|
|
188
|
+
obj = JSON.parse(content);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (typeof obj !== "object" || obj === null)
|
|
194
|
+
return null;
|
|
195
|
+
const rec = obj;
|
|
196
|
+
const pid = Number(rec.pid);
|
|
197
|
+
const startTimeMs = Number(rec.startTimeMs);
|
|
198
|
+
const port = Number(rec.port);
|
|
199
|
+
const flairVersion = typeof rec.flairVersion === "string" ? rec.flairVersion : "";
|
|
200
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
201
|
+
return null;
|
|
202
|
+
if (!Number.isFinite(startTimeMs))
|
|
203
|
+
return null;
|
|
204
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
205
|
+
return null;
|
|
206
|
+
return { pid, startTimeMs, port, flairVersion };
|
|
207
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upgrade-migrations.ts — flair#1439
|
|
3
|
+
*
|
|
4
|
+
* Version-keyed migrations that `flair upgrade` applies automatically during
|
|
5
|
+
* the post-restart verification step. These are distinct from the Harper data
|
|
6
|
+
* migrations in `resources/migrations/`: those run inside the server and
|
|
7
|
+
* transform stored data. These run in the CLI and bring the LOCAL dev-env
|
|
8
|
+
* (hook files, config snippets) up to the state a fresh `flair init` would
|
|
9
|
+
* produce for the harnesses already wired on this machine.
|
|
10
|
+
*
|
|
11
|
+
* ## Consent model
|
|
12
|
+
*
|
|
13
|
+
* A migration runs when ALL of the following hold:
|
|
14
|
+
* 1. The installed harness/artifact is ALREADY present on this machine
|
|
15
|
+
* (the user consented to that integration when they ran `flair init`).
|
|
16
|
+
* 2. The target version (`toVersion`) introduces the artifact for the first
|
|
17
|
+
* time (`toAtLeast`), AND the previous version did not have it
|
|
18
|
+
* (`fromBefore`).
|
|
19
|
+
* 3. The artifact is currently absent (idempotent: nothing to do if it's
|
|
20
|
+
* already there).
|
|
21
|
+
*
|
|
22
|
+
* Because (1) is the consent gate, no extra flag or TTY interaction is
|
|
23
|
+
* required. The user already said "wire Codex" — the migration just applies
|
|
24
|
+
* the piece their old init couldn't know about.
|
|
25
|
+
*
|
|
26
|
+
* ## Adding a new migration
|
|
27
|
+
*
|
|
28
|
+
* 1. Push a new entry onto UPGRADE_MIGRATIONS.
|
|
29
|
+
* 2. Set `fromBefore` to the first version that includes the new artifact.
|
|
30
|
+
* 3. Set `toAtLeast` to the same version.
|
|
31
|
+
* 4. Implement `apply(ctx)` — return ok:false (surfaced as a warning) on
|
|
32
|
+
* partial failure; throw only on hard failure that should be logged.
|
|
33
|
+
*
|
|
34
|
+
* Migrations run in order; later entries may assume earlier ones completed.
|
|
35
|
+
*/
|
|
36
|
+
import { installHook, hookSettingsPath, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
37
|
+
import { checkSessionStartHook, readClientMcpBlock } from "../doctor-client.js";
|
|
38
|
+
import { parseSemverCore } from "../fabric-upgrade.js";
|
|
39
|
+
// ─── Semver helpers ───────────────────────────────────────────────────────────
|
|
40
|
+
/** True if version `a` is strictly less than `b`. */
|
|
41
|
+
function semverLt(a, b) {
|
|
42
|
+
const pa = parseSemverCore(a);
|
|
43
|
+
const pb = parseSemverCore(b);
|
|
44
|
+
if (!pa || !pb)
|
|
45
|
+
return false;
|
|
46
|
+
if (pa[0] !== pb[0])
|
|
47
|
+
return pa[0] < pb[0];
|
|
48
|
+
if (pa[1] !== pb[1])
|
|
49
|
+
return pa[1] < pb[1];
|
|
50
|
+
return pa[2] < pb[2];
|
|
51
|
+
}
|
|
52
|
+
/** True if version `a` is greater than or equal to `b`. */
|
|
53
|
+
function semverGte(a, b) {
|
|
54
|
+
return !semverLt(a, b);
|
|
55
|
+
}
|
|
56
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Determine if a harness is "already wired" — the user previously ran
|
|
59
|
+
* `flair init` for it and the MCP block (config.toml / mcpServers JSON) is
|
|
60
|
+
* present. The hook itself is what we're migrating IN, so we deliberately
|
|
61
|
+
* do NOT check for the hook's presence here.
|
|
62
|
+
*
|
|
63
|
+
* Uses readClientMcpBlock (which temporarily overrides HOME) so the homeDir
|
|
64
|
+
* arg is honoured correctly in tests.
|
|
65
|
+
*/
|
|
66
|
+
function isHarnessWired(homeDir, harness) {
|
|
67
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
68
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
69
|
+
return block.present;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Install the SessionStart hook for a harness, reading the agent id and flair
|
|
73
|
+
* URL from the existing MCP block (same as `resolveUpgradeHookInstall` in
|
|
74
|
+
* doctor-run.ts, but self-contained to avoid a circular dep).
|
|
75
|
+
*/
|
|
76
|
+
function applySessionStartHookForHarness(homeDir, harness, port) {
|
|
77
|
+
const id = `session-start-hook@0.50.0/${harness}`;
|
|
78
|
+
// Already present → idempotent no-op.
|
|
79
|
+
const settingsPath = hookSettingsPath(homeDir, harness);
|
|
80
|
+
const existing = checkSessionStartHook(homeDir, settingsPath);
|
|
81
|
+
if (existing.present) {
|
|
82
|
+
return { id, message: `SessionStart hook (${harness}): already present — no-op`, ok: true, wrote: false };
|
|
83
|
+
}
|
|
84
|
+
// Not wired at all → skip (don't wire a harness the user never had).
|
|
85
|
+
if (!isHarnessWired(homeDir, harness)) {
|
|
86
|
+
return { id, message: `SessionStart hook (${harness}): harness not wired — skip`, ok: true, wrote: false };
|
|
87
|
+
}
|
|
88
|
+
// Resolve agentId + flairUrl from the existing MCP block.
|
|
89
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
90
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
91
|
+
const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
|
|
92
|
+
block.agentId;
|
|
93
|
+
const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
|
|
94
|
+
block.flairUrl ||
|
|
95
|
+
`http://127.0.0.1:${port}`;
|
|
96
|
+
if (!agentId) {
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
message: `SessionStart hook (${harness}): no agent id found in MCP config — run: flair hook install --harness ${harness}`,
|
|
100
|
+
ok: false,
|
|
101
|
+
wrote: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const result = installHook({ homeDir, harness, agentId, flairUrl });
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
message: result.message,
|
|
108
|
+
ok: result.ok,
|
|
109
|
+
wrote: result.ok,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// ─── Migration registry ───────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Ordered list of all CLI upgrade migrations.
|
|
115
|
+
*
|
|
116
|
+
* To add a migration: push a new entry, set fromBefore/toAtLeast, implement
|
|
117
|
+
* apply(). The runner calls apply() only when the version window matches.
|
|
118
|
+
*/
|
|
119
|
+
export const UPGRADE_MIGRATIONS = [
|
|
120
|
+
{
|
|
121
|
+
id: "session-start-hook@0.50.0",
|
|
122
|
+
description: "Install missing SessionStart hook(s) for harnesses already wired in a pre-0.50.0 init",
|
|
123
|
+
fromBefore: "0.50.0",
|
|
124
|
+
toAtLeast: "0.50.0",
|
|
125
|
+
apply(ctx) {
|
|
126
|
+
// Only act on detected hook-capable harnesses already wired on this machine.
|
|
127
|
+
const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h) && isHarnessWired(ctx.homeDir, h));
|
|
128
|
+
if (harnesses.length === 0)
|
|
129
|
+
return [];
|
|
130
|
+
return harnesses.map((h) => applySessionStartHookForHarness(ctx.homeDir, h, ctx.port));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
/**
|
|
135
|
+
* Run all CLI upgrade migrations whose version window matches
|
|
136
|
+
* `fromVersion → toVersion`. Migrations whose window is not matched are
|
|
137
|
+
* silently skipped (they're not pending for this upgrade pair).
|
|
138
|
+
*
|
|
139
|
+
* @param fromVersion The previously installed version (null → unknown, skip
|
|
140
|
+
* migrations that need a version gate).
|
|
141
|
+
* @param toVersion The newly installed version (null → unknown, skip all).
|
|
142
|
+
* @param ctx Runtime context (homeDir, port, detectedClientIds).
|
|
143
|
+
*/
|
|
144
|
+
export function applyUpgradeMigrations(fromVersion, toVersion, ctx,
|
|
145
|
+
// Injectable for tests (e.g. the throwing-migration case); production callers
|
|
146
|
+
// use the real registry.
|
|
147
|
+
migrations = UPGRADE_MIGRATIONS) {
|
|
148
|
+
const applied = [];
|
|
149
|
+
if (!fromVersion || !toVersion) {
|
|
150
|
+
// Cannot gate on version — skip all version-keyed migrations.
|
|
151
|
+
return { applied, allOk: true };
|
|
152
|
+
}
|
|
153
|
+
for (const migration of migrations) {
|
|
154
|
+
const pending = semverLt(fromVersion, migration.fromBefore) &&
|
|
155
|
+
semverGte(toVersion, migration.toAtLeast);
|
|
156
|
+
if (!pending)
|
|
157
|
+
continue;
|
|
158
|
+
// A migration must never CRASH the upgrade: a thrown apply() is strictly
|
|
159
|
+
// worse than the pre-migration doctor failure it was meant to prevent — no
|
|
160
|
+
// summary, partial writes, and the doctor catalog never runs. Catch it,
|
|
161
|
+
// surface it as a non-fatal ok:false result, and let the catalog proceed
|
|
162
|
+
// (flair#1439, per Kern review).
|
|
163
|
+
let results;
|
|
164
|
+
try {
|
|
165
|
+
results = migration.apply(ctx);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
results = [{
|
|
169
|
+
id: migration.id,
|
|
170
|
+
message: `${migration.id}: migration failed (${err instanceof Error ? err.message : String(err)}) — run \`flair doctor\` to check the current state`,
|
|
171
|
+
ok: false,
|
|
172
|
+
wrote: false,
|
|
173
|
+
}];
|
|
174
|
+
}
|
|
175
|
+
if (results.length > 0) {
|
|
176
|
+
applied.push({ migration, results });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const allOk = applied.every((a) => a.results.every((r) => r.ok));
|
|
180
|
+
return { applied, allOk };
|
|
181
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryArchive.ts — user-facing archive action (flair#1472, Deliverable A).
|
|
3
|
+
*
|
|
4
|
+
* POST /MemoryArchive — sets or clears the `archived` visibility flag on a
|
|
5
|
+
* memory by id:
|
|
6
|
+
* - `action: "basement"` → archived=true + stamps archivedAt (and archivedBy)
|
|
7
|
+
* - `action: "restore"` → archived=false + clears archivedAt/archivedBy
|
|
8
|
+
*
|
|
9
|
+
* `archived` is a VISIBILITY flag, not a deletion: basementing removes a
|
|
10
|
+
* memory from bootstrap + default search but leaves the row, its provenance,
|
|
11
|
+
* and its history fully intact (still retrievable via memory_get and
|
|
12
|
+
* memory_search(includeArchived:true)). Restore is the deliberate, GLOBAL
|
|
13
|
+
* inverse — it un-retires the memory for EVERY session, not a session-local
|
|
14
|
+
* view (per-session reuse is drawers, Deliverable B, which does not exist
|
|
15
|
+
* yet). The CLI help text must make that global scope explicit.
|
|
16
|
+
*
|
|
17
|
+
* Own-lane scope: the read uses Memory.get()'s read-scope gate and the write
|
|
18
|
+
* uses Memory.put()'s ownership gate (stampAttribution), so a caller can
|
|
19
|
+
* neither read nor write another agent's memory here. Anonymous HTTP is
|
|
20
|
+
* denied (401).
|
|
21
|
+
*
|
|
22
|
+
* Registered automatically at /MemoryArchive via config.yaml's
|
|
23
|
+
* `jsResource: files: dist/resources/*.js` (named export → export name).
|
|
24
|
+
*/
|
|
25
|
+
import { Resource } from "harper";
|
|
26
|
+
import { Memory } from "./Memory.js";
|
|
27
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
28
|
+
function json(status, body) {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Unwrap a Harper Response (has .json() + .status) into a plain object, else pass through. */
|
|
35
|
+
async function unwrap(value) {
|
|
36
|
+
if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
|
|
37
|
+
try {
|
|
38
|
+
const body = await value.json();
|
|
39
|
+
return { ...body, status: value.status };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { error: "request failed", status: value.status };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
export class MemoryArchive extends Resource {
|
|
48
|
+
/** POST requires auth — an agent acting on its own memories (or admin). */
|
|
49
|
+
async allowCreate() {
|
|
50
|
+
return allowVerified(this.getContext?.());
|
|
51
|
+
}
|
|
52
|
+
async post(data) {
|
|
53
|
+
const { id, action } = data || {};
|
|
54
|
+
if (!id)
|
|
55
|
+
return json(400, { error: "id required" });
|
|
56
|
+
if (action !== "basement" && action !== "restore") {
|
|
57
|
+
return json(400, { error: "action must be 'basement' or 'restore'" });
|
|
58
|
+
}
|
|
59
|
+
const ctx = this.getContext?.();
|
|
60
|
+
const auth = await resolveAgentAuth(ctx);
|
|
61
|
+
if (auth.kind === "anonymous")
|
|
62
|
+
return json(401, { error: "authentication required" });
|
|
63
|
+
if (auth.kind !== "agent")
|
|
64
|
+
return json(403, { error: "forbidden" });
|
|
65
|
+
// Read the existing record — Memory.get()'s read-scope gate applies (own +
|
|
66
|
+
// org-non-private only). A non-readable id returns a 404 Response.
|
|
67
|
+
const existing = await Memory.get(id, ctx);
|
|
68
|
+
const record = await unwrap(existing);
|
|
69
|
+
if (!record || typeof record !== "object" || !record.id) {
|
|
70
|
+
return json(404, { error: "memory not found" });
|
|
71
|
+
}
|
|
72
|
+
const archived = action === "basement";
|
|
73
|
+
const merged = {
|
|
74
|
+
...record,
|
|
75
|
+
archived,
|
|
76
|
+
updatedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
if (archived) {
|
|
79
|
+
// archivedBy is set by the caller (Memory.put() stamps archivedAt when
|
|
80
|
+
// archived===true). The content is unchanged, so the existing embedding
|
|
81
|
+
// stays valid — do NOT clear it (clearing would force a needless re-embed
|
|
82
|
+
// and, if the embedding engine is unavailable, silently drop the vector).
|
|
83
|
+
merged.archivedBy = auth.agentId;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
delete merged.archivedAt;
|
|
87
|
+
delete merged.archivedBy;
|
|
88
|
+
}
|
|
89
|
+
// Write back — Memory.put()'s ownership gate applies (stampAttribution), so
|
|
90
|
+
// a non-admin caller cannot flip another agent's memory (403).
|
|
91
|
+
const result = await Memory.put(merged, ctx);
|
|
92
|
+
return unwrap(result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -697,6 +697,7 @@ export class BootstrapMemories extends Resource {
|
|
|
697
697
|
conditions: [
|
|
698
698
|
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
699
699
|
{ attribute: "durability", comparator: "equals", value: "permanent" },
|
|
700
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
700
701
|
],
|
|
701
702
|
select: OWN_SELECT,
|
|
702
703
|
}));
|
|
@@ -767,6 +768,7 @@ export class BootstrapMemories extends Resource {
|
|
|
767
768
|
conditions: [
|
|
768
769
|
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
769
770
|
{ attribute: "durability", comparator: "not_equal", value: "permanent" },
|
|
771
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
770
772
|
],
|
|
771
773
|
select: OWN_SELECT,
|
|
772
774
|
sort: { attribute: "createdAt", descending: true },
|
|
@@ -952,7 +954,7 @@ export class BootstrapMemories extends Resource {
|
|
|
952
954
|
const candidatePoolK = Math.min(MAX_CANDIDATE_POOL, Math.max(3 * expectedFill, 5 * teammateIds.length, MIN_CANDIDATE_POOL));
|
|
953
955
|
const candidates = await retrieveCandidates({
|
|
954
956
|
queryEmbedding,
|
|
955
|
-
conditions: [scope.condition],
|
|
957
|
+
conditions: [scope.condition, { attribute: "archived", comparator: "not_equal", value: true }],
|
|
956
958
|
limit: candidatePoolK,
|
|
957
959
|
// flair#1246 — ONE RANKER, ONE SCALE: this pass now invokes the
|
|
958
960
|
// core in the SAME mode memory_search does (hybrid + q via the
|
|
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
|
|
|
61
61
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
62
62
|
// before reconsidering this default if the compositeScore formula or
|
|
63
63
|
// corpus changes.
|
|
64
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false } = data || {};
|
|
64
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
|
|
65
65
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
66
66
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
67
67
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -160,7 +160,12 @@ export class SemanticSearch extends Resource {
|
|
|
160
160
|
}
|
|
161
161
|
// Exclude archived records. Use "not_equal" (Harper v5 comparator) instead of
|
|
162
162
|
// "equals false" so records without the archived field are included.
|
|
163
|
-
|
|
163
|
+
// flair#1472 — `includeArchived` opts back IN to the basement: when true the
|
|
164
|
+
// archived predicate is omitted entirely, so basemented memories are returned
|
|
165
|
+
// under the SAME read-scope gate as a normal search (never a wider scope).
|
|
166
|
+
if (!includeArchived) {
|
|
167
|
+
conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
|
|
168
|
+
}
|
|
164
169
|
if (tag) {
|
|
165
170
|
conditions.push({ attribute: "tags", comparator: "equals", value: tag });
|
|
166
171
|
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* per-source scoping — see resources/AttentionQuery.ts's module doc), so the
|
|
11
11
|
* MCP surface inherits the SAME security model as the signed-REST path. There
|
|
12
12
|
* is no raw CRUD surface — the only way to reach the datastore through /mcp is
|
|
13
|
-
* via one of these
|
|
13
|
+
* via one of these 14 semantic tools.
|
|
14
14
|
*
|
|
15
15
|
* memory_search · memory_store · memory_update · memory_get · memory_delete ·
|
|
16
|
+
* memory_basement · memory_restore ·
|
|
16
17
|
* bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent ·
|
|
17
18
|
* attention · record_usage
|
|
18
19
|
*
|
|
@@ -184,6 +185,10 @@ async function memorySearch(agent, args) {
|
|
|
184
185
|
// requested so a plain search delegates a byte-identical body.
|
|
185
186
|
if (args?.abstain === true)
|
|
186
187
|
body.abstain = true;
|
|
188
|
+
// flair#1472 — opt-in basement inclusion. Forwarded ONLY when requested so a
|
|
189
|
+
// plain search delegates a byte-identical body (archived excluded by default).
|
|
190
|
+
if (args?.includeArchived === true)
|
|
191
|
+
body.includeArchived = true;
|
|
187
192
|
return unwrap(await h.post(body));
|
|
188
193
|
}
|
|
189
194
|
async function memoryStore(agent, args) {
|
|
@@ -351,6 +356,56 @@ async function memoryUpdate(agent, args) {
|
|
|
351
356
|
// regenerates the vector server-side); no-op when the response carries none.
|
|
352
357
|
return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
|
|
353
358
|
}
|
|
359
|
+
// ── flair#1472 memory_basement / memory_restore ──────────────────────────────
|
|
360
|
+
// The user-facing archive action. `archived` is a VISIBILITY flag, not a
|
|
361
|
+
// deletion: basementing a memory removes it from bootstrap + default search
|
|
362
|
+
// but leaves the row, its provenance, and its history fully intact (still
|
|
363
|
+
// retrievable via memory_get and memory_search(includeArchived:true)). Restore
|
|
364
|
+
// is the deliberate, GLOBAL inverse — it un-retires the memory for EVERY
|
|
365
|
+
// session, not a session-local view (that is drawers, Deliverable B, which does
|
|
366
|
+
// not exist yet). Both are writes scoped to the caller's own lane: the read
|
|
367
|
+
// uses Memory.get()'s read-scope gate and the write uses Memory.put()'s
|
|
368
|
+
// ownership gate (stampAttribution), so a caller can neither read nor write
|
|
369
|
+
// another agent's memory here.
|
|
370
|
+
async function memoryBasement(agent, args) {
|
|
371
|
+
const Cls = await handler("Memory");
|
|
372
|
+
const id = args?.id;
|
|
373
|
+
const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
|
|
374
|
+
if (!existing || existing.error != null || existing.status === 404) {
|
|
375
|
+
return { error: "memory not found", status: 404 };
|
|
376
|
+
}
|
|
377
|
+
const merged = {
|
|
378
|
+
...existing,
|
|
379
|
+
archived: true,
|
|
380
|
+
archivedBy: agent.agentId,
|
|
381
|
+
updatedAt: new Date().toISOString(),
|
|
382
|
+
};
|
|
383
|
+
// Memory.put() stamps archivedAt when archived===true (see Memory.ts). The
|
|
384
|
+
// content is unchanged, so the existing embedding stays valid — do NOT clear
|
|
385
|
+
// it (clearing would force a needless re-embed and, if the embedding engine
|
|
386
|
+
// is unavailable, would silently drop the vector).
|
|
387
|
+
if (agent.clientId)
|
|
388
|
+
merged.claimedClient = agent.clientId;
|
|
389
|
+
return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
|
|
390
|
+
}
|
|
391
|
+
async function memoryRestore(agent, args) {
|
|
392
|
+
const Cls = await handler("Memory");
|
|
393
|
+
const id = args?.id;
|
|
394
|
+
const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
|
|
395
|
+
if (!existing || existing.error != null || existing.status === 404) {
|
|
396
|
+
return { error: "memory not found", status: 404 };
|
|
397
|
+
}
|
|
398
|
+
const merged = {
|
|
399
|
+
...existing,
|
|
400
|
+
archived: false,
|
|
401
|
+
updatedAt: new Date().toISOString(),
|
|
402
|
+
};
|
|
403
|
+
delete merged.archivedAt;
|
|
404
|
+
delete merged.archivedBy;
|
|
405
|
+
if (agent.clientId)
|
|
406
|
+
merged.claimedClient = agent.clientId;
|
|
407
|
+
return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
|
|
408
|
+
}
|
|
354
409
|
async function memoryGet(agent, args) {
|
|
355
410
|
const Cls = await handler("Memory");
|
|
356
411
|
// flair#1181 — by-id reads MUST use the STATIC `Cls.get(id, context)` form,
|
|
@@ -635,6 +690,7 @@ export const TOOLS = {
|
|
|
635
690
|
limit: { type: "number", description: "Max results (default 5)" },
|
|
636
691
|
includeTrust: { type: "boolean", description: "Attach a per-result trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
|
|
637
692
|
abstain: { type: "boolean", description: "Opt into first-class abstention: when the best match is below a global confidence threshold, return { abstained: true, reason, bestScore } with no weak matches instead of the N weakest results. Default false." },
|
|
693
|
+
includeArchived: { type: "boolean", description: "Include basemented (archived) memories in results. Default false — archived memories are excluded from normal search. When true, archived memories are returned under the SAME read-scope gate as a normal search (never a wider scope)." },
|
|
638
694
|
},
|
|
639
695
|
required: ["query"],
|
|
640
696
|
},
|
|
@@ -712,6 +768,55 @@ export const TOOLS = {
|
|
|
712
768
|
errorShape: { trigger: "updating a non-existent id", fields: ["error", "status"] },
|
|
713
769
|
},
|
|
714
770
|
},
|
|
771
|
+
memory_basement: {
|
|
772
|
+
def: {
|
|
773
|
+
name: "memory_basement",
|
|
774
|
+
description: "Send a memory to the basement (archive it). Sets archived=true and stamps archivedAt. " +
|
|
775
|
+
"The memory is removed from bootstrap and default search but remains retrievable via " +
|
|
776
|
+
"memory_get and memory_search(includeArchived:true). Deliberate and GLOBAL — this is a " +
|
|
777
|
+
"visibility flag, not a deletion: provenance and history are untouched. Scoped to your own memories only.",
|
|
778
|
+
inputSchema: {
|
|
779
|
+
type: "object",
|
|
780
|
+
properties: {
|
|
781
|
+
id: { type: "string", description: "ID of the memory to basement (archive)" },
|
|
782
|
+
},
|
|
783
|
+
required: ["id"],
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
impl: memoryBasement,
|
|
787
|
+
contract: {
|
|
788
|
+
summary: "Write echo of the archived record { id, archived:true, archivedAt, ... }. No internal embedding fields; the flip round-trips via memory_get.",
|
|
789
|
+
requiredFields: ["id", "archived"],
|
|
790
|
+
fieldTypes: { id: "string", archived: "boolean" },
|
|
791
|
+
forbiddenFields: INTERNAL_MEMORY_FIELDS,
|
|
792
|
+
invariants: { fullyResolved: true },
|
|
793
|
+
errorShape: { trigger: "basementing a non-existent or non-owned id", fields: ["error", "status"] },
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
memory_restore: {
|
|
797
|
+
def: {
|
|
798
|
+
name: "memory_restore",
|
|
799
|
+
description: "Restore a basemented (archived) memory. Clears archived and archivedAt. Deliberate and GLOBAL — " +
|
|
800
|
+
"this un-retires the memory for EVERY session, not a session-local view (per-session reuse is " +
|
|
801
|
+
"drawers, which do not exist yet). Scoped to your own memories only.",
|
|
802
|
+
inputSchema: {
|
|
803
|
+
type: "object",
|
|
804
|
+
properties: {
|
|
805
|
+
id: { type: "string", description: "ID of the memory to restore (un-archive)" },
|
|
806
|
+
},
|
|
807
|
+
required: ["id"],
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
impl: memoryRestore,
|
|
811
|
+
contract: {
|
|
812
|
+
summary: "Write echo of the restored record { id, archived:false, ... }. No internal embedding fields; the flip round-trips via memory_get.",
|
|
813
|
+
requiredFields: ["id", "archived"],
|
|
814
|
+
fieldTypes: { id: "string", archived: "boolean" },
|
|
815
|
+
forbiddenFields: INTERNAL_MEMORY_FIELDS,
|
|
816
|
+
invariants: { fullyResolved: true },
|
|
817
|
+
errorShape: { trigger: "restoring a non-existent or non-owned id", fields: ["error", "status"] },
|
|
818
|
+
},
|
|
819
|
+
},
|
|
715
820
|
memory_get: {
|
|
716
821
|
def: {
|
|
717
822
|
name: "memory_get",
|