@tpsdev-ai/flair 0.51.0 → 0.51.2

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.
@@ -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
- conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
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 12 semantic tools.
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",