@tpsdev-ai/flair 0.51.2 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1037 -566
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/launchd-repair.js +198 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
|
@@ -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
|
|
323
|
-
//
|
|
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).
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
|
239
|
-
continue; // already
|
|
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
|
|
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
|
+
}
|