@tpsdev-ai/flair 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +13 -8
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -13,7 +13,7 @@
13
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
+ * memory_basement · memory_restore · skill_store · skill_search · skill_get ·
17
17
  * bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent ·
18
18
  * attention · record_usage
19
19
  *
@@ -36,9 +36,26 @@
36
36
  * version bump + a new FlairClient method, out of scope for this query-only
37
37
  * slice.
38
38
  */
39
+ /**
40
+ * The delegated handler classes, held in a mutable registry, LAZILY loaded on
41
+ * first use. Two reasons for the indirection:
42
+ *
43
+ * 1. Tests inject capture doubles via `__setHandlers` WITHOUT `mock.module`-ing
44
+ * the shared `resources/*.ts` files (a process-global bun mock that leaks
45
+ * into every other test file).
46
+ * 2. The handler classes statically `import { Resource, databases } from
47
+ * "harper"`. Importing them lazily (dynamic import on first tool
48
+ * call, not at module top) keeps `mcp-tools`/`mcp-handler` free of a
49
+ * top-level Harper link, so importing the /mcp handler in a unit test never
50
+ * requires the full Harper module surface up front.
51
+ *
52
+ * Prod: first tool call loads the real classes against a fully-real Harper.
53
+ */
54
+ import { AUTHORITY_FIELDS } from "./authority-field-guard.js";
39
55
  import { resolveVersion } from "./version.js";
40
56
  import { agentContext, adminContext, collectionResource } from "./in-process.js";
41
57
  import { RECORD_USAGE_ID_MERGE_CONTRACT, unionUsageMemoryIds } from "./usage-ids.js";
58
+ import { SKILL_TAG, isSkillWrite } from "./skill-write.js";
42
59
  const H = {};
43
60
  const LOADERS = {
44
61
  SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
@@ -253,6 +270,157 @@ async function memoryStore(agent, args) {
253
270
  // tool ever inlines the vector. No-op when the response carries none.
254
271
  return stripInternalFields(await unwrap(await h.post(body)));
255
272
  }
273
+ /**
274
+ * skill_store — write a skill-tagged Memory (flair#1542 component 2).
275
+ *
276
+ * A skill is a Memory tagged "skill" (reuse the substrate — no new table).
277
+ * This tool is a thin wrapper over the SAME Memory.post() write path as
278
+ * memory_store — it re-implements NO business logic. The three skill-specific
279
+ * rules (embed from `trigger`, SkillScan gate before the embed, forced
280
+ * durability=persistent) are enforced SERVER-SIDE in resources/Memory.ts /
281
+ * resources/skill-write.ts, so this wrapper only shapes the body:
282
+ *
283
+ * - `trigger` → Memory.trigger (the "when to use" text — the recall signal)
284
+ * - `content` → Memory.content (the full procedure)
285
+ * - `tags` → Memory.tags (with "skill" prepended)
286
+ * - `name`/`description` → folded into Memory.metadata (opaque JSON blob,
287
+ * store-and-return, never parsed server-side) so the later skill recall
288
+ * tools can surface them.
289
+ *
290
+ * durability is NOT set here — the server forces it to "persistent" for any
291
+ * skill-tagged write (and rejects an explicit ephemeral/session), so a caller
292
+ * cannot accidentally write a reaped skill.
293
+ */
294
+ async function skillStore(agent, args) {
295
+ const Cls = await handler("Memory");
296
+ const h = await collectionResource(Cls, delegationContext(agent));
297
+ const body = {
298
+ agentId: agent.agentId,
299
+ content: args?.content,
300
+ trigger: args?.trigger,
301
+ tags: ["skill", ...(Array.isArray(args?.tags) ? args.tags : [])],
302
+ };
303
+ // flair#718 authorship-provenance — same claimedClient passthrough as
304
+ // memory_store (never a tool argument; sourced from the resolved token).
305
+ if (agent.clientId)
306
+ body.claimedClient = agent.clientId;
307
+ // name/description are SKILL.md frontmatter fields with no dedicated Memory
308
+ // column; fold them into the opaque metadata blob.
309
+ const meta = {};
310
+ if (typeof args?.name === "string" && args.name.length > 0)
311
+ meta.name = args.name;
312
+ if (typeof args?.description === "string" && args.description.length > 0)
313
+ meta.description = args.description;
314
+ if (Object.keys(meta).length > 0)
315
+ body.metadata = JSON.stringify(meta);
316
+ return stripInternalFields(await unwrap(await h.post(body)));
317
+ }
318
+ /**
319
+ * flair#1546 — the lightweight skill CATALOG card. skill_search is progressive
320
+ * disclosure: it returns "which skill applies", never the full procedure. A card
321
+ * carries ONLY id/name/trigger/description/tags/agentId — enough to choose a
322
+ * skill — and the caller then skill_get's the chosen id for the procedure.
323
+ *
324
+ * `name`/`description` are SKILL.md frontmatter with no dedicated Memory column;
325
+ * skill_store folds them into the opaque `metadata` JSON blob, so this parses
326
+ * them back out defensively (a corrupt/absent blob simply yields no name/desc).
327
+ * `content` (the procedure) and the raw embedding are DELIBERATELY absent — the
328
+ * skill_search contract forbids them on each card.
329
+ */
330
+ function projectSkillCard(r) {
331
+ let name;
332
+ let description;
333
+ if (typeof r?.metadata === "string" && r.metadata.length > 0) {
334
+ try {
335
+ const m = JSON.parse(r.metadata);
336
+ if (m && typeof m === "object") {
337
+ if (typeof m.name === "string")
338
+ name = m.name;
339
+ if (typeof m.description === "string")
340
+ description = m.description;
341
+ }
342
+ }
343
+ catch { /* opaque/corrupt metadata → no name/description on the card */ }
344
+ }
345
+ return {
346
+ id: r?.id,
347
+ name,
348
+ trigger: r?.trigger,
349
+ description,
350
+ tags: r?.tags,
351
+ agentId: r?.agentId,
352
+ };
353
+ }
354
+ /**
355
+ * skill_search — recall skill-tagged Memories for a task (flair#1546 component 1).
356
+ *
357
+ * The core recall tool, and a THIN wrapper over the SAME SemanticSearch handler
358
+ * as memory_search — it re-implements NO retrieval or scoping logic. It rides:
359
+ * - a tags-equals seek for the "skill" tag (only skill rows are candidates);
360
+ * - the HNSW leg over the stored embedding — which for a skill row IS the
361
+ * `trigger` embedding (#1543), so the task is ranked against "when to use";
362
+ * - the 'query' inputType SemanticSearch already applies to the task string;
363
+ * - resolveReadScope (own any-visibility + every non-private row) — NEVER
364
+ * another agent's private skill.
365
+ * Identity is the RESOLVED agent; no body agentId is forwarded, so a caller can
366
+ * never widen scope past its own read-scope (the SemanticSearch cross-agent
367
+ * guard would 403 a mismatch anyway).
368
+ *
369
+ * The drawer / working-set scope named in the spec is Deliverable B and does
370
+ * NOT exist yet (see resources/MemoryArchive.ts / memory_restore's doc) — there
371
+ * is no working-set layer to compose with, so scope today is exactly
372
+ * resolveReadScope's open-within-org read. When drawers land, this rides
373
+ * whatever scope SemanticSearch resolves, for free.
374
+ */
375
+ async function skillSearch(agent, args) {
376
+ const Cls = await handler("SemanticSearch");
377
+ const h = new Cls(undefined, delegationContext(agent));
378
+ const res = await unwrap(await h.post({
379
+ q: args?.task,
380
+ tag: SKILL_TAG,
381
+ limit: args?.limit ?? 5,
382
+ // name/description live in the metadata blob; trigger is not in
383
+ // DEFAULT_SELECT — opt both into the projection so the card can carry them.
384
+ includeMetadata: true,
385
+ includeTrigger: true,
386
+ }));
387
+ // A guard/error Response unwraps to `{ error, status }` (no `results`) — pass
388
+ // it through untouched so the caller sees the structured refusal.
389
+ if (!res || typeof res !== "object" || !Array.isArray(res.results))
390
+ return res;
391
+ // Progressive disclosure: return the CATALOG (lightweight cards), never the
392
+ // full procedure. `_warning` / any other top-level keys pass through.
393
+ return { ...res, results: res.results.map(projectSkillCard) };
394
+ }
395
+ /**
396
+ * skill_get — the full skill by id (flair#1546 component 2).
397
+ *
398
+ * A thin wrapper over Memory.get() — the SAME by-id read as memory_get, under
399
+ * the SAME read-scope gate (makeByIdReadGate → resolveReadScope): a non-owner
400
+ * cannot read another agent's PRIVATE skill (it 404s), exactly as a private
401
+ * memory does. skill_get is the disclosure step after skill_search's catalog:
402
+ * it returns the full procedure (`content`) + trigger + metadata.
403
+ *
404
+ * It is a SKILL tool, not a general reader: a readable id that is NOT a skill
405
+ * returns the same 404 as an unreadable id. Returning it would (a) make
406
+ * skill_get an alias for memory_get, and (b) reveal a readable non-skill
407
+ * memory's existence through a skill-shaped call; a uniform 404 does neither.
408
+ * The embedding fields are stripped by default, same as memory_get.
409
+ */
410
+ async function skillGet(agent, args) {
411
+ const Cls = await handler("Memory");
412
+ // flair#1181 — by-id reads use the STATIC `Cls.get(id, context)` form (see
413
+ // memoryGet for the full rationale). Read-scope is enforced inside Memory.get.
414
+ const result = await unwrap(await Cls.get(args?.id, delegationContext(agent)));
415
+ // A NOT_FOUND / unreadable id unwraps to `{ error, status }` — pass it through.
416
+ if (!result || typeof result !== "object" || result.error != null)
417
+ return result;
418
+ // Skill-only: a readable non-skill row is reported as not found rather than
419
+ // returned (see the doc above).
420
+ if (!isSkillWrite(result))
421
+ return { error: "skill not found", status: 404 };
422
+ return args?.includeEmbedding === true ? result : stripInternalFields(result);
423
+ }
256
424
  /**
257
425
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
258
426
  * integrity fix). Mirrors flair-client's MemoryApi.update() (packages/
@@ -311,6 +479,10 @@ async function memoryUpdate(agent, args) {
311
479
  delete record.validFrom;
312
480
  delete record.validTo;
313
481
  delete record.archivedAt;
482
+ // A successor contains new, unreviewed content; keep the verdict on its
483
+ // predecessor instead of claiming that the new version was approved.
484
+ for (const field of AUTHORITY_FIELDS.Memory)
485
+ delete record[field];
314
486
  // flair#1189 — retrievalCount and lastRetrieved are RECORD-scoped, not
315
487
  // lineage-scoped: a brand-new successor record has no retrieval history of
316
488
  // its OWN, so it must start with none. Inheriting them from the superseded
@@ -319,8 +491,9 @@ async function memoryUpdate(agent, args) {
319
491
  // existed"), silently corrupting any recency/usage-based ranking that reads
320
492
  // these fields. Reset both here, at succession construction — NOT server-
321
493
  // side, because `supersedes` is a PERMANENT property of every successor and
322
- // legitimate later retrievalCount bumps route through put() on a record that
323
- // still carries it. Usage/citation-ledger counters (usageCount, the #1147
494
+ // legitimate later retrievalCount bumps route through MemoryHitStat
495
+ // (resources/hit-tracking.ts) on the successor's own id. Usage/citation-
496
+ // ledger counters (usageCount, the #1147
324
497
  // citation ledger) are a SEPARATE, arguably lineage-scoped question and are
325
498
  // deliberately left untouched here (#1147's usage loop is currently inert).
326
499
  record.retrievalCount = 0;
@@ -742,6 +915,97 @@ export const TOOLS = {
742
915
  errorShape: { trigger: "an unrecognized visibility value (e.g. \"prvate\")", fields: ["error", "status"], mustNotLeak: INTERNAL_MEMORY_FIELDS },
743
916
  },
744
917
  },
918
+ skill_store: {
919
+ def: {
920
+ name: "skill_store",
921
+ description: "Write a skill (a reusable capability/procedure) as a skill-tagged memory. " +
922
+ "The `trigger` text is what the skill embeds from (the recall signal — 'when to use this'), " +
923
+ "and `content` is the full procedure. Skills are forced durability=persistent and are " +
924
+ "SkillScan-gated before the embed (a dangerous shell/network payload is rejected).",
925
+ inputSchema: {
926
+ type: "object",
927
+ properties: {
928
+ content: { type: "string", description: "The full procedure (markdown body of the SKILL.md)" },
929
+ trigger: { type: "string", description: "The 'when to use' text — the recall signal the skill embeds from" },
930
+ name: { type: "string", description: "Skill name (SKILL.md frontmatter; stored in metadata)" },
931
+ description: { type: "string", description: "Skill description (SKILL.md frontmatter; stored in metadata)" },
932
+ tags: { type: "array", items: { type: "string" }, description: "Additional tags (the 'skill' tag is added automatically)" },
933
+ },
934
+ required: ["content"],
935
+ },
936
+ },
937
+ impl: skillStore,
938
+ contract: {
939
+ summary: "Write echo { id, written:true, deduplicated } for the skill-tagged memory. No internal embedding fields; round-trips via memory_get.",
940
+ requiredFields: ["id", "written"],
941
+ fieldTypes: { id: "string", written: "boolean", deduplicated: "boolean" },
942
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
943
+ invariants: { fullyResolved: true },
944
+ errorShape: { trigger: "a skill whose trigger/content fails SkillScan (high/critical risk)", fields: ["error", "status"], mustNotLeak: INTERNAL_MEMORY_FIELDS },
945
+ },
946
+ },
947
+ skill_search: {
948
+ def: {
949
+ name: "skill_search",
950
+ description: "Find skills (reusable capabilities/procedures) that apply to a task. " +
951
+ "Ranks skill-tagged memories by their `trigger` ('when to use') against your task text. " +
952
+ "Returns a lightweight CATALOG — id, name, trigger, description, tags, agentId — NOT the full " +
953
+ "procedure (fetch that with skill_get). Scoped to your own + shared skills; another agent's " +
954
+ "private skill is never returned.",
955
+ annotations: { readOnlyHint: true },
956
+ inputSchema: {
957
+ type: "object",
958
+ properties: {
959
+ task: { type: "string", description: "The task/context to match skills against — natural language; ranked against each skill's trigger" },
960
+ limit: { type: "number", description: "Max skills to return (default 5)" },
961
+ },
962
+ required: ["task"],
963
+ },
964
+ },
965
+ impl: skillSearch,
966
+ contract: {
967
+ summary: "{ results: SkillCard[] } — the skill catalog (lightweight id/name/trigger/description/tags/agentId, " +
968
+ "ranked by trigger match); the full procedure and the raw embedding are never on a card. Scoped to the " +
969
+ "caller's own + non-private skills; another agent's private skill is never returned.",
970
+ requiredFields: ["results"],
971
+ fieldTypes: { results: "array" },
972
+ invariants: {
973
+ selfDescribingEmpty: [{ path: "results", type: "array" }],
974
+ // Each card carries id, and NEVER the full procedure (`content`) or the
975
+ // raw embedding — the progressive-disclosure guarantee.
976
+ containerRules: [{ container: "results", requiredFields: ["id"], forbiddenFields: [...INTERNAL_MEMORY_FIELDS, "content"] }],
977
+ fullyResolved: true,
978
+ },
979
+ },
980
+ },
981
+ skill_get: {
982
+ def: {
983
+ name: "skill_get",
984
+ description: "Retrieve a full skill by ID — the complete procedure (`content`) plus trigger and metadata. " +
985
+ "The disclosure step after skill_search's catalog. Read-scoped: you can only get your own or a " +
986
+ "shared skill, never another agent's private skill. A non-skill id returns not-found.",
987
+ annotations: { readOnlyHint: true },
988
+ inputSchema: {
989
+ type: "object",
990
+ properties: {
991
+ id: { type: "string", description: "Skill (memory) ID" },
992
+ includeEmbedding: { type: "boolean", description: "Include the raw embedding vector (large, rarely useful). Default false." },
993
+ },
994
+ required: ["id"],
995
+ },
996
+ },
997
+ impl: skillGet,
998
+ contract: {
999
+ summary: "The full skill record { id, agentId, content, trigger, tags, durability, metadata, createdAt, ... } for a " +
1000
+ "skill readable under the caller's read-scope — embedding + embeddingModel stripped by default. A non-owner " +
1001
+ "cannot read another agent's private skill, and a readable non-skill id is not found (both 404).",
1002
+ requiredFields: ["id", "agentId", "content", "createdAt"],
1003
+ fieldTypes: { id: "string", agentId: "string", content: "string" },
1004
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
1005
+ invariants: { fullyResolved: true },
1006
+ errorShape: { trigger: "get a non-skill / unreadable / another agent's private id (404)", fields: ["error", "status"] },
1007
+ },
1008
+ },
745
1009
  memory_update: {
746
1010
  def: {
747
1011
  name: "memory_update",
@@ -855,9 +1119,9 @@ export const TOOLS = {
855
1119
  },
856
1120
  impl: memoryDelete,
857
1121
  contract: {
858
- summary: "Deletes the caller's own memory (success echo is thin). The permanent-memory guard returns { error, status:403 } for a non-admin; the row round-trips as gone via memory_get.",
1122
+ summary: "Deletes the caller's own memory at any durability tier (success echo is thin). Cross-owner deletion returns { error, status:403 } for a non-admin; a deleted row round-trips as gone via memory_get.",
859
1123
  invariants: { fullyResolved: true },
860
- errorShape: { trigger: "a non-admin deletes a permanent memory", fields: ["error", "status"] },
1124
+ errorShape: { trigger: "a non-admin deletes another agent's memory", fields: ["error", "status"] },
861
1125
  },
862
1126
  },
863
1127
  bootstrap: {
@@ -1000,7 +1264,7 @@ export const TOOLS = {
1000
1264
  soul_set: {
1001
1265
  def: {
1002
1266
  name: "soul_set",
1003
- description: "Set a personality or project context entry. Included in every bootstrap.",
1267
+ description: "Soul changes require operator credentials through the REST API or CLI; runtime tool calls are refused.",
1004
1268
  inputSchema: {
1005
1269
  type: "object",
1006
1270
  properties: {
@@ -1012,8 +1276,9 @@ export const TOOLS = {
1012
1276
  },
1013
1277
  impl: soulSet,
1014
1278
  contract: {
1015
- summary: "Writes a soul entry keyed `${agentId}:${key}`, attributed to the caller. Correctness is proven by the soul_get round-trip this is the write flair#1181 broke on the connector path.",
1279
+ summary: "Refuses runtime Soul writes, including admin-agent delegation, with { error, status:403 }. Operators use the authenticated REST or CLI path.",
1016
1280
  invariants: { fullyResolved: true },
1281
+ errorShape: { trigger: "a runtime attempts to write Soul", fields: ["error", "status"] },
1017
1282
  },
1018
1283
  },
1019
1284
  soul_get: {
@@ -19,6 +19,24 @@
19
19
  * to be reviewed for promotion, not raw source data.
20
20
  */
21
21
  export const MAX_CANDIDATES_PER_RUN = 10;
22
+ /**
23
+ * Per-run gather cap for distillation input (#1515). Tens, not thousands —
24
+ * a 3k backlog must drain across nights instead of one blocking run.
25
+ * Duplicated by value in src/rem/runner.ts (npm-packaging boundary).
26
+ */
27
+ export const DEFAULT_MAX_MEMORIES_PER_RUN = 50;
28
+ /**
29
+ * Hard ceiling on FLAIR_REM_MAX_MEMORIES / request maxMemories. An operator
30
+ * who sets 3000 would recreate the outage this cap exists to prevent.
31
+ * Duplicated by value in src/rem/runner.ts.
32
+ */
33
+ export const ABSOLUTE_MAX_MEMORIES_PER_RUN = 200;
34
+ /**
35
+ * Event-loop yield budget (ms) during the gather scan. Same convention as
36
+ * MemoryDedupStats: after this much synchronous work, await setImmediate
37
+ * so /Health and reads can run. Not a throughput claim.
38
+ */
39
+ export const REM_GATHER_YIELD_BUDGET_MS = 10;
22
40
  /**
23
41
  * Max characters per candidate claim. Candidates are meant to be atomic,
24
42
  * single-insight lessons (matches the "Keep each memory atomic" instruction
@@ -554,3 +572,96 @@ export function buildStagedCandidateRow(params) {
554
572
  }
555
573
  return row;
556
574
  }
575
+ /** A memory is unreflected when lastReflected is missing or blank. */
576
+ export function isUnreflectedMemory(record) {
577
+ return record.lastReflected == null || record.lastReflected === "";
578
+ }
579
+ /**
580
+ * Resolve the per-run gather cap: explicit override > FLAIR_REM_MAX_MEMORIES
581
+ * (positive, finite) > DEFAULT_MAX_MEMORIES_PER_RUN, then clamp to
582
+ * ABSOLUTE_MAX_MEMORIES_PER_RUN. Exported for tests and the nightly runner's
583
+ * duplicated resolver (kept in sync by value).
584
+ */
585
+ export function resolveMaxMemoriesPerRun(override, env = process.env) {
586
+ let resolved = DEFAULT_MAX_MEMORIES_PER_RUN;
587
+ if (typeof override === "number" && Number.isFinite(override) && override > 0) {
588
+ resolved = Math.floor(override);
589
+ }
590
+ else {
591
+ const fromEnv = Number(env.FLAIR_REM_MAX_MEMORIES);
592
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
593
+ resolved = Math.floor(fromEnv);
594
+ }
595
+ return Math.min(resolved, ABSOLUTE_MAX_MEMORIES_PER_RUN);
596
+ }
597
+ /**
598
+ * Stamp `lastReflected` only after generateCandidates succeeds on an
599
+ * execute run. Prompt-only (`execute: false`) and failed generate/abort
600
+ * must leave the pointer unset so the next night can retry (#1515 Bugbot).
601
+ */
602
+ export function shouldStampLastReflected(opts) {
603
+ return opts.execute === true && opts.generateSucceeded === true;
604
+ }
605
+ /** Oldest createdAt first. Missing timestamps sort last (never "oldest"). */
606
+ export function compareOldestCreatedAtFirst(a, b) {
607
+ const ac = a.createdAt ?? "";
608
+ const bc = b.createdAt ?? "";
609
+ if (!ac && !bc)
610
+ return 0;
611
+ if (!ac)
612
+ return 1;
613
+ if (!bc)
614
+ return -1;
615
+ return ac.localeCompare(bc);
616
+ }
617
+ /**
618
+ * Unreflected first, then oldest createdAt. Nightly uses this so a backlog
619
+ * of never-reflected rows drains before anything is re-distilled.
620
+ */
621
+ export function compareOldestUnreflectedFirst(a, b) {
622
+ const aU = isUnreflectedMemory(a) ? 0 : 1;
623
+ const bU = isUnreflectedMemory(b) ? 0 : 1;
624
+ if (aU !== bU)
625
+ return aU - bU;
626
+ return compareOldestCreatedAtFirst(a, b);
627
+ }
628
+ /**
629
+ * Keep at most `maxN` records, oldest-unreflected first. Already-reflected
630
+ * rows lose to any unreflected row (so a 3k backlog drains) but still fill
631
+ * leftover slots when fewer than `maxN` unreflected matches exist — a
632
+ * tagged/recent gather after a prior reflect must not go empty.
633
+ * Mutates `pool` and returns it (bounded insert, O(N) with N ≤ 200).
634
+ */
635
+ export function considerForOldestUnreflectedCap(pool, record, maxN) {
636
+ if (maxN <= 0)
637
+ return pool;
638
+ if (pool.length < maxN) {
639
+ pool.push(record);
640
+ pool.sort(compareOldestUnreflectedFirst);
641
+ return pool;
642
+ }
643
+ const worstKept = pool[pool.length - 1];
644
+ if (compareOldestUnreflectedFirst(record, worstKept) < 0) {
645
+ pool[pool.length - 1] = record;
646
+ pool.sort(compareOldestUnreflectedFirst);
647
+ }
648
+ return pool;
649
+ }
650
+ /**
651
+ * True when an operator asked REM to stop: FLAIR_REM_PAUSE=1 or the pause
652
+ * sentinel exists. MemoryReflect checks this between gather yields so
653
+ * `flair rem pause` aborts an in-flight scan without restarting Harper.
654
+ * `existsSync` is injected so unit tests do not touch the real home directory.
655
+ */
656
+ export function isRemAbortRequested(env = process.env, existsSyncImpl, pauseFlagPath) {
657
+ if (env.FLAIR_REM_PAUSE === "1")
658
+ return true;
659
+ if (!existsSyncImpl || !pauseFlagPath)
660
+ return false;
661
+ try {
662
+ return existsSyncImpl(pauseFlagPath);
663
+ }
664
+ catch {
665
+ return false;
666
+ }
667
+ }
@@ -127,6 +127,7 @@
127
127
  */
128
128
  import { databases } from "harper";
129
129
  import { getModelId } from "../embeddings-provider.js";
130
+ import { currentSpaceRawForms, isCurrentSpaceStamp } from "../embedding-space-guard.js";
130
131
  function defaultMemoryTable() {
131
132
  return databases.flair.Memory;
132
133
  }
@@ -192,11 +193,28 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
192
193
  // reads the live record directly. Reverting this to "not_equal" would
193
194
  // reopen #807 on any store where the embeddingModel index lags the
194
195
  // on-disk value.
196
+ // embedding-space-guard slice 1: getModelId() now stamps the
197
+ // ENGINE-QUALIFIED id (`gguf:<base>[+searchprefix]`). Today's corpus is
198
+ // stamped with the BARE name, which denotes the SAME space — so a row is
199
+ // current-space iff its stamp is EITHER the qualified id OR its bare
200
+ // equivalent. `currentSpaceRawForms()` returns both; stale = matches
201
+ // NEITHER (an AND of `not_equals`), so a legacy bare row is NOT re-embedded
202
+ // (which would loop forever — Memory.put re-stamps it qualified, still
203
+ // "!= bare" under a single-value check). Each leg stays the `not_equals`
204
+ // PREFIX form (flair#807: resolves to a negated-equals leaf that bypasses a
205
+ // possibly-stale secondary index and reads the live record).
206
+ const forms = currentSpaceRawForms(getCurrentModelId());
207
+ const notCurrentSpace = forms.length === 1
208
+ ? { attribute: "embeddingModel", comparator: "not_equals", value: forms[0] }
209
+ : {
210
+ operator: "and",
211
+ conditions: forms.map((f) => ({ attribute: "embeddingModel", comparator: "not_equals", value: f })),
212
+ };
195
213
  return [
196
214
  {
197
215
  operator: "or",
198
216
  conditions: [
199
- { attribute: "embeddingModel", comparator: "not_equals", value: getCurrentModelId() },
217
+ notCurrentSpace,
200
218
  { attribute: "embeddingModel", comparator: "equals", value: null },
201
219
  ],
202
220
  },
@@ -235,8 +253,8 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
235
253
  const existing = await table.get(id);
236
254
  if (!existing)
237
255
  continue; // deleted since the search above — nothing to fix
238
- if (existing.embeddingModel === current)
239
- continue; // already stamped by a concurrent runner — idempotent skip
256
+ if (isCurrentSpaceStamp(existing.embeddingModel, current))
257
+ continue; // already current-space (incl. bare equivalent) — idempotent skip
240
258
  const ok = await regen(id, existing);
241
259
  if (ok)
242
260
  touchedIds.push(id);
@@ -273,7 +291,7 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
273
291
  // A row that vanished since the search, OR whose live embeddingModel
274
292
  // still doesn't match current, is a GENUINE pending row (or a
275
293
  // concurrent delete) — never counted as a false positive.
276
- if (existing && existing.embeddingModel === current)
294
+ if (existing && isCurrentSpaceStamp(existing.embeddingModel, current))
277
295
  falsePositives++;
278
296
  }
279
297
  return { sampled: ids.length, falsePositives };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * owner-field-guard.ts — the single resource-layer delegate that enforces
3
+ * owner-field immutability for principal-owning tables.
4
+ *
5
+ * ─── Why this is at the resource layer, not the middleware ────────────────────
6
+ *
7
+ * The shared record-ownership guard (record-owner-guard.ts, applied by
8
+ * auth-middleware.ts) already refuses a non-owner mutating a stored row, on
9
+ * every verb, reading ownership from stored state. What it CANNOT do is decide
10
+ * whether a write CHANGES the owner field, because that needs the request body —
11
+ * and Harper's middleware Request exposes no parsed body (no `.json()`, no
12
+ * `.clone()`; `.body` is a single-consumption stream). So this rule lives one
13
+ * layer down, where Harper hands the resource its parsed `content`.
14
+ *
15
+ * That is exactly why resources/Agent.ts enforces its analogous rule (a
16
+ * principal's admin status) in the resource and not the middleware. This file is
17
+ * the generalisation of that pattern for the ordinary owner field, kept in ONE
18
+ * place so the per-resource put()/patch() overrides are a thin delegation rather
19
+ * than N hand-written checks that could drift apart.
20
+ *
21
+ * ─── The rule ────────────────────────────────────────────────────────────────
22
+ *
23
+ * A non-admin caller may not write a value into a stored record's owner field
24
+ * that differs from the value already there.
25
+ *
26
+ * Admin and internal callers pass through. A create (no stored record yet) is
27
+ * not this rule's business — no-forge attribution on creation is each resource's
28
+ * own job. The decision itself is the pure `isForbiddenOwnerFieldChange`
29
+ * (record-owner-guard.ts), so it is unit-tested without a Harper instance.
30
+ */
31
+ import { resolveAgentAuth } from "./agent-auth.js";
32
+ import { isForbiddenOwnerFieldChange } from "./record-owner-guard.js";
33
+ /**
34
+ * Enforce owner-field immutability for one write.
35
+ *
36
+ * @param self the resource instance (`this`)
37
+ * @param getExisting a thunk that returns the STORED record — pass `() => super.get()`
38
+ * so the raw base-table read is used rather than any get() override
39
+ * @param content the write content (the caller's claim)
40
+ * @param ownerField the column naming the owning principal for this table
41
+ * @returns a 403 Response to send, or null to proceed
42
+ */
43
+ export async function guardOwnerFieldImmutable(self, getExisting, content, ownerField) {
44
+ const auth = await resolveAgentAuth(self.getContext?.());
45
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
46
+ return null;
47
+ // Any caller that is not a resolved agent (anonymous, or an identity that did
48
+ // not resolve) has no owner identity to match against, so it may not proceed
49
+ // on an owner-bearing write path — fail closed. The denial is 401, not 403:
50
+ // an unresolved caller is unauthenticated, which is the same contract every
51
+ // principal-owning resource already returns for anonymous writes, and the
52
+ // owner-field rule only applies to a caller that HAS an owner identity. This
53
+ // also narrows `auth` to the agent variant, so `auth.agentId` below is typed.
54
+ if (auth.kind !== "agent") {
55
+ return new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "content-type": "application/json" } });
56
+ }
57
+ const existing = (await Promise.resolve(getExisting()).catch(() => null));
58
+ if (isForbiddenOwnerFieldChange(existing, content, ownerField, auth.agentId)) {
59
+ return new Response(JSON.stringify({ error: "forbidden: the owner of a record cannot be changed" }), { status: 403, headers: { "content-type": "application/json" } });
60
+ }
61
+ return null;
62
+ }
@@ -0,0 +1,29 @@
1
+ import { withDetachedTxn } from "./table-helpers.js";
2
+ import { databases } from "harper";
3
+ import { noteMemoryUpsert } from "./bm25-index-service.js";
4
+ // Called only after the promotion workflow has authorized and written a Memory
5
+ // through its resource (safety, embedding, ownership and provenance still run).
6
+ // Auto-promotion supplies its enumeration context: its Memory write used a
7
+ // separate agentContext, so that enumeration snapshot cannot see the new row.
8
+ // Manual promotion keeps the stamp in its own write transaction.
9
+ export async function stampMemoryPromotion(id, reviewerId, decidedAt, enumerationContext) {
10
+ const table = databases.flair.Memory;
11
+ const stored = await withDetachedTxn(enumerationContext, () => table.get(id));
12
+ if (!stored)
13
+ throw new Error(`Promotion memory ${id} was not written`);
14
+ const row = { ...stored, promotionStatus: "approved", promotedBy: reviewerId, promotedAt: decidedAt };
15
+ await withDetachedTxn(enumerationContext, () => table.put(row));
16
+ noteMemoryUpsert(row);
17
+ }
18
+ /** Stamp after a successful Memory write. Failures must not abort a sweep or
19
+ * leave a candidate pending (that re-writes the same claim next cycle). */
20
+ export async function stampMemoryPromotionIsolated(id, reviewerId, decidedAt, enumerationContext) {
21
+ try {
22
+ await stampMemoryPromotion(id, reviewerId, decidedAt, enumerationContext);
23
+ return true;
24
+ }
25
+ catch (err) {
26
+ console.warn(`stampMemoryPromotion failed for ${id}: ${err?.message ?? err}`);
27
+ return false;
28
+ }
29
+ }