@tpsdev-ai/flair 0.51.2 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +6 -5
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -0,0 +1,204 @@
1
+ // Pure promotion policy shared by the CLI and trusted server workflow.
2
+ export function validatePromoteOpts(opts) {
3
+ if (!opts.rationale || !opts.rationale.trim()) {
4
+ return "--rationale is required (per spec § 5: no rubber-stamp)";
5
+ }
6
+ if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
7
+ return "--to must be 'soul' or 'memory'";
8
+ }
9
+ if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
10
+ return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
11
+ }
12
+ return null;
13
+ }
14
+ export function validateRejectOpts(opts) {
15
+ if (!opts.reason || !opts.reason.trim()) {
16
+ return "--reason is required";
17
+ }
18
+ return null;
19
+ }
20
+ /**
21
+ * Decide whether a promote/reject action can proceed against a candidate's
22
+ * current state, and what message to surface to the operator. Pure function;
23
+ * action side effects happen in the CLI body after this returns ok.
24
+ */
25
+ export function decideCandidateAction(candidate, action) {
26
+ if (!candidate)
27
+ return { ok: false, severity: "error", message: "candidate not found" };
28
+ const status = candidate.status;
29
+ if (status === "promoted") {
30
+ return action === "promote"
31
+ ? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
32
+ : { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
33
+ }
34
+ if (status === "rejected") {
35
+ return action === "reject"
36
+ ? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
37
+ : { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
38
+ }
39
+ return { ok: true };
40
+ }
41
+ // ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
42
+ // ADK session records are written by adk-flair (memory_service.py) under a
43
+ // SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
44
+ // compound scope tag `adk:<app>:<user>`. That tag is the access-control
45
+ // boundary. A candidate distilled from those records therefore MUST carry the
46
+ // scope tag when promoted, or the promoted claim lands in the shared agentId
47
+ // memory retrievable by every other user of the app — a cross-user leak.
48
+ //
49
+ // `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
50
+ // and DROPPED the source tag. We now propagate the source scope tag for
51
+ // ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
52
+ // ADK-sourced but its scope tag can't be uniquely+completely determined.
53
+ //
54
+ // SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
55
+ // candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
56
+ // as before — a transient/deleted source on a non-ADK candidate must NOT block
57
+ // its promotion.
58
+ //
59
+ // SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
60
+ // is detected here by re-reading the candidate's source memories and inspecting
61
+ // their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
62
+ // of whose source memories are unreadable (deleted/transient) yields no `adk:`
63
+ // evidence and is treated as non-ADK. Closing that corner without regressing
64
+ // non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
65
+ // onto the MemoryCandidate row at distillation time (it distills per single
66
+ // scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
67
+ // written so that override can be threaded in later without touching callers.
68
+ export const ADK_SCOPE_TAG_PREFIX = "adk:";
69
+ /**
70
+ * Decide the tag set for a promoted Memory given the candidate id and the
71
+ * result of fetching each of its source memories. Pure — no I/O; the action
72
+ * callback does the fetching and threads the results here so this is unit-
73
+ * testable and the fail-closed logic is exercised directly.
74
+ *
75
+ * `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
76
+ * anticipated): the authoritative scope:"tagged" tag the distillation engine
77
+ * stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
78
+ * buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
79
+ * the source re-read entirely — the engine distilled under exactly this one
80
+ * tag, so it knows the per-user scope tag independent of whether the source
81
+ * memories are still readable. This closes the residual fail-open the SEAM
82
+ * note describes: a candidate all of whose sources are unreadable yields no
83
+ * `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
84
+ * tagless into the shared agentId namespace (a cross-user leak). Threading it
85
+ * in as an optional trailing arg keeps every pre-#1205b caller (and every
86
+ * candidate that never carried a stamp) on the unchanged source-re-read path.
87
+ *
88
+ * With NO stamp (undefined/empty) the source-re-read classification runs
89
+ * exactly as in #1205a:
90
+ * - No `adk:` scope tag across readable sources → NON-ADK candidate; return
91
+ * the provenance tags only (unchanged behavior).
92
+ * - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
93
+ * return [scopeTag, ...provenance].
94
+ * - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
95
+ * OR incomplete (some source unreadable) → REFUSE (fail-closed): a
96
+ * tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
97
+ * not a benign miss.
98
+ */
99
+ export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
100
+ const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
101
+ // #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
102
+ // re-read sources. This is the seam closure: correctness no longer depends on
103
+ // source readability. `adkSourced` (which gates the Soul-promotion refusal in
104
+ // the promote action) tracks whether the stamped tag is an ADK scope tag.
105
+ if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
106
+ return {
107
+ ok: true,
108
+ tags: [stampedScopeTag, ...provenance],
109
+ adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
110
+ };
111
+ }
112
+ const adkTags = new Set();
113
+ let anySourceUnreadable = false;
114
+ for (const s of sources) {
115
+ if (!s.ok) {
116
+ anySourceUnreadable = true;
117
+ continue;
118
+ }
119
+ for (const t of s.tags) {
120
+ if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
121
+ adkTags.add(t);
122
+ }
123
+ }
124
+ // No positive ADK evidence → non-ADK. An unreadable source with zero ADK
125
+ // evidence does NOT fail closed here (that would regress non-ADK promotion);
126
+ // see the SEAM note above.
127
+ if (adkTags.size === 0) {
128
+ return { ok: true, tags: provenance, adkSourced: false };
129
+ }
130
+ if (adkTags.size > 1) {
131
+ return {
132
+ ok: false,
133
+ reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
134
+ };
135
+ }
136
+ if (anySourceUnreadable) {
137
+ return {
138
+ ok: false,
139
+ reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
140
+ };
141
+ }
142
+ const scopeTag = [...adkTags][0];
143
+ return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
144
+ }
145
+ // ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
146
+ // Continuity-journal scope tag prefix. Canonical string duplicated in
147
+ // resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
148
+ // packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
149
+ // npm-packaging boundary (see this file's header) and cannot import them; kept
150
+ // in sync by the shared canonical string, same discipline as
151
+ // MACHINE_REVIEWER_* below.
152
+ export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
153
+ /**
154
+ * Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
155
+ * (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
156
+ * decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
157
+ * default-private-unless ruling covers BOTH promotion paths: the sources of a
158
+ * continuity candidate are the most sensitive tier (ephemeral+private journal
159
+ * rows), so leaving visibility unset here would let Memory's durability-keyed
160
+ * default widen it to shared ("persistent" defaults shared) — a silent
161
+ * visibility escalation. "shared" only when the candidate is continuity-scoped
162
+ * AND carries the distiller's affirmative ruling WITH its recorded
163
+ * team-relevance justification; every other case — including every
164
+ * uncertainty — is "private".
165
+ *
166
+ * Returns undefined for NON-continuity candidates: their visibility behavior
167
+ * (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
168
+ * deliberately not changed here.
169
+ */
170
+ export function derivePromotedVisibility(candidate) {
171
+ const scopeTag = candidate.scopeTag;
172
+ const isContinuity = typeof scopeTag === "string" &&
173
+ scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
174
+ scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
175
+ if (!isContinuity)
176
+ return undefined;
177
+ if (candidate.visibilityRuling !== "shared")
178
+ return "private";
179
+ const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
180
+ return rationale.length > 0 ? "shared" : "private";
181
+ }
182
+ // ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
183
+ // A promotion records a reviewerId that feeds audit/attribution
184
+ // (schemas/memory.graphql:209). An automated (machine-driven) promotion path
185
+ // must record a reviewerId that can NEVER be mistaken for a human/agent
186
+ // reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
187
+ // for that, and forbid the human `--reviewer` path from claiming it.
188
+ export const MACHINE_REVIEWER_PREFIX = "machine:";
189
+ /** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
190
+ export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
191
+ /** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
192
+ * denotes an automated path, not a human or agent reviewer. */
193
+ export function isMachineReviewerId(id) {
194
+ return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
195
+ }
196
+ /** The human `flair rem promote` path must not record a reviewerId in the
197
+ * reserved machine namespace — that would launder automated attribution onto
198
+ * a human-operated promotion. Returns an error string, or null if allowed. */
199
+ export function validateHumanReviewerId(reviewerId) {
200
+ if (isMachineReviewerId(reviewerId)) {
201
+ return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
202
+ }
203
+ return null;
204
+ }
@@ -7,8 +7,11 @@
7
7
  *
8
8
  * Approach: client-side. The CLI sequentially calls existing `/Memory` and
9
9
  * `/Soul` endpoints (DELETE current rows for the agent, PUT snapshot rows).
10
- * No new server endpoint keeps the auth surface unchanged and avoids the
11
- * Harper body-size limit on uploading large memory exports inline.
10
+ * Soul DELETE/PUT must use `soulApiCall` (operator Basic / deliberate
11
+ * internal). Snapshot souls carry `agentId`, so a bare `apiCall` signs as
12
+ * that agent and the source gate 403s. Souls are PUT before memories, and
13
+ * leftover MemoryCandidate rows for the agent are deleted first, so
14
+ * restored identity is not refused as learned content.
12
15
  *
13
16
  * Reversibility-of-restore guarantee: before any destructive op, this
14
17
  * module creates a pre-restore snapshot of the CURRENT state. If something
@@ -35,6 +38,18 @@ function asArray(raw) {
35
38
  }
36
39
  return [];
37
40
  }
41
+ function soulWrite(opts) {
42
+ return opts.soulApiCall ?? opts.apiCall;
43
+ }
44
+ async function listAgentCandidates(apiCall, agentId) {
45
+ return asArray(await apiCall("POST", "/MemoryCandidate/search_by_conditions", {
46
+ operator: "and",
47
+ conditions: [
48
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
49
+ ],
50
+ get_attributes: ["id", "claim"],
51
+ }));
52
+ }
38
53
  function parseJsonlSafe(text) {
39
54
  if (!text.trim())
40
55
  return [];
@@ -52,8 +67,8 @@ function parseJsonlSafe(text) {
52
67
  * 3. Verify metadata.agentId matches opts.agentId (prevents accidental
53
68
  * cross-agent restore — the file might have been hand-copied).
54
69
  * 4. Create a pre-restore snapshot of current state (skip in dry-run).
55
- * 5. Fetch + delete current memories/souls for the agent (skip in dry-run).
56
- * 6. PUT snapshot memories/souls back into Harper (skip in dry-run).
70
+ * 5. Fetch + delete current memories/souls/candidates for the agent (skip in dry-run).
71
+ * 6. PUT snapshot souls, then memories (skip in dry-run).
57
72
  * 7. Return counts.
58
73
  *
59
74
  * On any error after step 4: the result reports `status: "failed"` with
@@ -65,7 +80,7 @@ export async function applySnapshot(opts) {
65
80
  status: "completed",
66
81
  agentId: opts.agentId,
67
82
  snapshotPath: opts.snapshotPath,
68
- deleted: { memories: 0, souls: 0 },
83
+ deleted: { memories: 0, souls: 0, candidates: 0 },
69
84
  restored: { memories: 0, souls: 0 },
70
85
  errors,
71
86
  };
@@ -122,12 +137,15 @@ export async function applySnapshot(opts) {
122
137
  }
123
138
  if (opts.dryRun) {
124
139
  // In dry-run, report planned counts. Still fetch current state for
125
- // accurate deleted-counts reporting.
140
+ // accurate deleted-counts reporting, including leftover candidates
141
+ // that --apply will wipe before Soul PUT.
126
142
  try {
127
143
  const currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
128
144
  const currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
145
+ const currentCandidates = await listAgentCandidates(opts.apiCall, opts.agentId);
129
146
  result.deleted.memories = currentMem.length;
130
147
  result.deleted.souls = currentSouls.length;
148
+ result.deleted.candidates = currentCandidates.length;
131
149
  result.restored.memories = memories.length;
132
150
  result.restored.souls = souls.length;
133
151
  result.status = "dry-run";
@@ -163,7 +181,16 @@ export async function applySnapshot(opts) {
163
181
  rmSync(tmp, { recursive: true, force: true });
164
182
  return result;
165
183
  }
166
- // 5. Delete current memories + souls (sequential to keep error semantics).
184
+ // 5. Delete current memories, souls, and leftover candidates. Candidates
185
+ // are not in the snapshot, but refuseLearnedSoulWrite matches claim text,
186
+ // so a leftover row 403s Soul PUT after operator auth succeeds.
187
+ let currentCandidates = [];
188
+ try {
189
+ currentCandidates = await listAgentCandidates(opts.apiCall, opts.agentId);
190
+ }
191
+ catch (err) {
192
+ errors.push(`fetch-candidates: ${err?.message ?? String(err)}`);
193
+ }
167
194
  for (const m of currentMem) {
168
195
  if (!m?.id)
169
196
  continue;
@@ -179,36 +206,49 @@ export async function applySnapshot(opts) {
179
206
  if (!s?.id)
180
207
  continue;
181
208
  try {
182
- await opts.apiCall("DELETE", `/Soul/${encodeURIComponent(String(s.id))}`);
209
+ await soulWrite(opts)("DELETE", `/Soul/${encodeURIComponent(String(s.id))}`);
183
210
  result.deleted.souls++;
184
211
  }
185
212
  catch (err) {
186
213
  errors.push(`delete-soul ${s.id}: ${err?.message ?? String(err)}`);
187
214
  }
188
215
  }
189
- // 6. PUT snapshot rows.
190
- for (const m of memories) {
191
- if (!m?.id)
216
+ for (const c of currentCandidates) {
217
+ if (!c?.id)
192
218
  continue;
193
219
  try {
194
- await opts.apiCall("PUT", `/Memory/${encodeURIComponent(String(m.id))}`, m);
195
- result.restored.memories++;
220
+ await opts.apiCall("DELETE", `/MemoryCandidate/${encodeURIComponent(String(c.id))}`);
221
+ result.deleted.candidates++;
196
222
  }
197
223
  catch (err) {
198
- errors.push(`put-memory ${m.id}: ${err?.message ?? String(err)}`);
224
+ errors.push(`delete-candidate ${c.id}: ${err?.message ?? String(err)}`);
199
225
  }
200
226
  }
227
+ // 6. PUT snapshot rows. Souls first: refuseLearnedSoulWrite matches the
228
+ // target agent's Memory text, so memories-then-souls 403s a previously
229
+ // valid snapshot even with operator credentials.
201
230
  for (const s of souls) {
202
231
  if (!s?.id)
203
232
  continue;
204
233
  try {
205
- await opts.apiCall("PUT", `/Soul/${encodeURIComponent(String(s.id))}`, s);
234
+ await soulWrite(opts)("PUT", `/Soul/${encodeURIComponent(String(s.id))}`, s);
206
235
  result.restored.souls++;
207
236
  }
208
237
  catch (err) {
209
238
  errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
210
239
  }
211
240
  }
241
+ for (const m of memories) {
242
+ if (!m?.id)
243
+ continue;
244
+ try {
245
+ await opts.apiCall("PUT", `/Memory/${encodeURIComponent(String(m.id))}`, m);
246
+ result.restored.memories++;
247
+ }
248
+ catch (err) {
249
+ errors.push(`put-memory ${m.id}: ${err?.message ?? String(err)}`);
250
+ }
251
+ }
212
252
  // 7. Verify post-restore state (default on; opt-out via verifyPostRestore=false).
213
253
  // Catches silent failures: Harper schema coercion, 4xx responses the
214
254
  // apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,