@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.
- package/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- 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/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
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
*/
|
|
55
55
|
import { Resource, databases } from "harper";
|
|
56
56
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
57
|
+
import { setImmediate as yieldToRequests } from "node:timers/promises";
|
|
57
58
|
import { dirname } from "node:path";
|
|
58
59
|
import { allowAdmin } from "./agent-auth.js";
|
|
59
60
|
import { retrieveCandidates } from "./semantic-retrieval-core.js";
|
|
@@ -82,11 +83,19 @@ async function computeAndPersist(ctx, opts) {
|
|
|
82
83
|
// exactly what this stat is for). Bounded to the most-recently-created
|
|
83
84
|
// maxMemories when the instance exceeds the safety cap (see
|
|
84
85
|
// dedup-cluster.ts's doc on why this is a defensive bound, not sampling).
|
|
86
|
+
let yieldAt = performance.now() + 10;
|
|
85
87
|
const all = [];
|
|
86
88
|
for await (const record of databases.flair.Memory.search({
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Harper 5.2.8 starts a standalone not_equal index scan at true,
|
|
90
|
+
// skipping false/missing archive keys. Filter the primary scan instead.
|
|
91
|
+
select: ["id", "embedding", "createdAt", "archived"],
|
|
89
92
|
})) {
|
|
93
|
+
if (performance.now() >= yieldAt) {
|
|
94
|
+
await yieldToRequests();
|
|
95
|
+
yieldAt = performance.now() + 10;
|
|
96
|
+
}
|
|
97
|
+
if (record.archived === true)
|
|
98
|
+
continue;
|
|
90
99
|
if (!record?.id || !Array.isArray(record.embedding) || record.embedding.length === 0)
|
|
91
100
|
continue;
|
|
92
101
|
all.push({ id: record.id, embedding: record.embedding, createdAt: record.createdAt });
|
|
@@ -110,6 +119,12 @@ async function computeAndPersist(ctx, opts) {
|
|
|
110
119
|
// at the documented annK, rather than silently examining annK-1.
|
|
111
120
|
const edges = [];
|
|
112
121
|
for (const memory of sweepSet) {
|
|
122
|
+
// Awaiting cached table reads can keep draining microtasks indefinitely.
|
|
123
|
+
// Give the HTTP event loop a turn between bounded chunks of ANN work.
|
|
124
|
+
if (performance.now() >= yieldAt) {
|
|
125
|
+
await yieldToRequests();
|
|
126
|
+
yieldAt = performance.now() + 10;
|
|
127
|
+
}
|
|
113
128
|
let neighbors;
|
|
114
129
|
try {
|
|
115
130
|
neighbors = await retrieveCandidates({
|
|
@@ -2,8 +2,10 @@ import { Resource, databases } from "harper";
|
|
|
2
2
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
|
|
4
4
|
import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
|
|
5
|
+
import { guardAuthorityFields, stripAuthorityFields } from "./authority-field-guard.js";
|
|
5
6
|
import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
|
|
6
7
|
import { assertValidDurability } from "./memory-durability.js";
|
|
8
|
+
import { enforceSkillDurability, skillScanGate } from "./skill-write.js";
|
|
7
9
|
import { noteMemoryUpsert } from "./bm25-index-service.js";
|
|
8
10
|
export class FeedMemories extends Resource {
|
|
9
11
|
// Self-authorize via the Ed25519 agent verify (the auth reshape removes the
|
|
@@ -56,6 +58,20 @@ export class FeedMemories extends Resource {
|
|
|
56
58
|
headers: { "Content-Type": "application/json" },
|
|
57
59
|
});
|
|
58
60
|
}
|
|
61
|
+
// ── flair#1542: skill-tagged writes are gated (SkillScan + forced durability) ──
|
|
62
|
+
// This endpoint writes via the RAW table object below — NOT Memory.post()/
|
|
63
|
+
// put() — so a caller could spread tags:["skill"] + trigger into the raw
|
|
64
|
+
// put and land an unscanned, 30-day-reapable (durability=standard) skill.
|
|
65
|
+
// Run the SAME gate Memory.post() runs, BEFORE the durability default is
|
|
66
|
+
// computed so a forced "persistent" flows into the tier rule below.
|
|
67
|
+
{
|
|
68
|
+
const skillScanDenial = skillScanGate(content);
|
|
69
|
+
if (skillScanDenial)
|
|
70
|
+
return skillScanDenial;
|
|
71
|
+
const skillDurabilityDenial = enforceSkillDurability(content);
|
|
72
|
+
if (skillDurabilityDenial)
|
|
73
|
+
return skillDurabilityDenial;
|
|
74
|
+
}
|
|
59
75
|
// ── Write-side durability/visibility validation (#1009/#1238/#1257) ─────
|
|
60
76
|
// This endpoint writes via the RAW table object below — NOT the exported
|
|
61
77
|
// Memory resource — so it inherits NONE of Memory.post()/put()'s write
|
|
@@ -96,6 +112,19 @@ export class FeedMemories extends Resource {
|
|
|
96
112
|
});
|
|
97
113
|
}
|
|
98
114
|
}
|
|
115
|
+
// ── Authority-field guard (#1524 leftover) ────────────────────────────
|
|
116
|
+
// Same raw-table bypass as the durability/visibility block above:
|
|
117
|
+
// guardAuthorityFields sits on Memory.put/patch/post, not the raw
|
|
118
|
+
// handle. A verified agent could POST {promotionStatus:"approved"}
|
|
119
|
+
// here and land a forged verdict. Refuse a body that sets or changes
|
|
120
|
+
// a stamp, then unconditionally strip before the raw put so even
|
|
121
|
+
// stamps the guard would restore onto an omitted-field update cannot
|
|
122
|
+
// ride a feed write (feed ingest is not a promotion-stamp path).
|
|
123
|
+
{
|
|
124
|
+
const authorityDenial = await guardAuthorityFields(() => content?.id ? databases.flair.Memory.get(content.id) : undefined, content, "Memory");
|
|
125
|
+
if (authorityDenial)
|
|
126
|
+
return authorityDenial;
|
|
127
|
+
}
|
|
99
128
|
const now = new Date().toISOString();
|
|
100
129
|
const contentHash = computeContentHash(agentId, body);
|
|
101
130
|
const existing = await findExistingMemoryByContentHash(databases.flair.Memory.search(), agentId, contentHash);
|
|
@@ -125,6 +154,7 @@ export class FeedMemories extends Resource {
|
|
|
125
154
|
if (record.durability === "ephemeral" && (record.visibility === undefined || record.visibility === null)) {
|
|
126
155
|
record.visibility = PRIVATE_VISIBILITY;
|
|
127
156
|
}
|
|
157
|
+
stripAuthorityFields(record, "Memory");
|
|
128
158
|
await databases.flair.Memory.put(record);
|
|
129
159
|
// flair#1357 — raw-table write: hook it explicitly (see bm25-index-service).
|
|
130
160
|
noteMemoryUpsert(record);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { databases } from "harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
|
+
import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
|
|
3
4
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
@@ -93,7 +94,20 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
93
94
|
content.createdAt ||= new Date().toISOString();
|
|
94
95
|
return super.post(content, context);
|
|
95
96
|
}
|
|
97
|
+
// PATCH routes past put(), so ownerId immutability is enforced on both verbs
|
|
98
|
+
// via the one shared delegate. Only the owner may modify a grant, and not even
|
|
99
|
+
// the owner may re-point ownerId at another principal (a grantee never could —
|
|
100
|
+
// the middleware ownership guard refuses a non-owner mutation first).
|
|
101
|
+
async patch(content, query) {
|
|
102
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "ownerId");
|
|
103
|
+
if (denial)
|
|
104
|
+
return denial;
|
|
105
|
+
return super.patch(content, query);
|
|
106
|
+
}
|
|
96
107
|
async put(content, context) {
|
|
108
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "ownerId");
|
|
109
|
+
if (denial)
|
|
110
|
+
return denial;
|
|
97
111
|
const denied = await this._enforceOwnerWrite(content);
|
|
98
112
|
if (denied)
|
|
99
113
|
return denied;
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* agentId string — which agent to reflect on
|
|
12
12
|
* scope string — "recent" | "tagged" | "all" (default: "recent")
|
|
13
13
|
* since string? — ISO timestamp lower bound (default: 24h ago)
|
|
14
|
-
* maxMemories number? — cap (default: 50
|
|
14
|
+
* maxMemories number? — cap (default: 50, env FLAIR_REM_MAX_MEMORIES,
|
|
15
|
+
* hard ceiling 200). Oldest-unreflected first.
|
|
15
16
|
* focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" | "continuity"
|
|
16
17
|
* (default: "lessons_learned"; a continuity-tag run — scope="tagged" with an
|
|
17
18
|
* adk:continuity:* tag — always uses "continuity", flair#1257 slice 3)
|
|
@@ -42,9 +43,19 @@
|
|
|
42
43
|
*/
|
|
43
44
|
import { Resource, databases, models, logger } from "harper";
|
|
44
45
|
import { randomBytes } from "node:crypto";
|
|
46
|
+
import { existsSync } from "node:fs";
|
|
47
|
+
import { resolve } from "node:path";
|
|
48
|
+
import { homedir } from "node:os";
|
|
49
|
+
import { setImmediate as yieldToRequests } from "node:timers/promises";
|
|
45
50
|
import { isAdmin, allowVerified } from "./agent-auth.js";
|
|
46
51
|
import { patchRecordSilent } from "./table-helpers.js";
|
|
47
|
-
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, isContinuityScopeTag, filterStaleSessionIntentCandidates, resolveCandidateVisibilityRuling, DEFAULT_STALE_INTENT_HORIZON_MS, } from "./memory-reflect-lib.js";
|
|
52
|
+
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, isContinuityScopeTag, filterStaleSessionIntentCandidates, resolveCandidateVisibilityRuling, considerForOldestUnreflectedCap, isRemAbortRequested, resolveMaxMemoriesPerRun, shouldStampLastReflected, DEFAULT_STALE_INTENT_HORIZON_MS, REM_GATHER_YIELD_BUDGET_MS, } from "./memory-reflect-lib.js";
|
|
53
|
+
/** Same path `flair rem pause` writes. Duplicated across the src/ boundary. */
|
|
54
|
+
const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
55
|
+
const GATHER_SELECT = [
|
|
56
|
+
"id", "agentId", "archived", "durability", "expiresAt", "tags",
|
|
57
|
+
"createdAt", "lastReflected", "content",
|
|
58
|
+
];
|
|
48
59
|
export class ReflectMemories extends Resource {
|
|
49
60
|
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
50
61
|
// admin elevation). Any verified agent may reflect; the isAdmin checks in post()
|
|
@@ -53,7 +64,7 @@ export class ReflectMemories extends Resource {
|
|
|
53
64
|
return allowVerified(this.getContext?.());
|
|
54
65
|
}
|
|
55
66
|
async post(data) {
|
|
56
|
-
const { agentId: bodyAgentId, scope = "recent", since, maxMemories
|
|
67
|
+
const { agentId: bodyAgentId, scope = "recent", since, maxMemories, focus = "lessons_learned", tag, execute = false, } = data || {};
|
|
57
68
|
// Authenticated identity comes from getContext().request, not this.request
|
|
58
69
|
// (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
|
|
59
70
|
// check was silently bypassed — bob could reflect on alice's memories and
|
|
@@ -72,8 +83,26 @@ export class ReflectMemories extends Resource {
|
|
|
72
83
|
const agentId = actorResolution.agentId;
|
|
73
84
|
const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
|
|
74
85
|
const gatherNow = new Date();
|
|
86
|
+
const maxN = resolveMaxMemoriesPerRun(typeof maxMemories === "number" ? maxMemories : undefined);
|
|
87
|
+
// #1515: do not take the first N search hits. Scan eligible rows (yielding
|
|
88
|
+
// so /Health keeps serving), keep oldest-unreflected first up to maxN
|
|
89
|
+
// (already-reflected fill leftover slots), and abort if the operator
|
|
90
|
+
// paused mid-run. Embeddings are excluded from the select — loading 3k
|
|
91
|
+
// vectors just to strip them pegs the main thread.
|
|
75
92
|
const memories = [];
|
|
76
|
-
|
|
93
|
+
let unreflectedSeen = 0;
|
|
94
|
+
let yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
|
|
95
|
+
for await (const record of databases.flair.Memory.search({ select: GATHER_SELECT })) {
|
|
96
|
+
if (performance.now() >= yieldAt) {
|
|
97
|
+
if (isRemAbortRequested(process.env, existsSync, REM_PAUSE_FLAG)) {
|
|
98
|
+
return new Response(JSON.stringify({
|
|
99
|
+
error: "rem_aborted",
|
|
100
|
+
detail: "REM distillation aborted (pause sentinel or FLAIR_REM_PAUSE=1). Resume with: flair rem resume",
|
|
101
|
+
}), { status: 503 });
|
|
102
|
+
}
|
|
103
|
+
await yieldToRequests();
|
|
104
|
+
yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
|
|
105
|
+
}
|
|
77
106
|
if (record.agentId !== agentId)
|
|
78
107
|
continue;
|
|
79
108
|
if (record.archived)
|
|
@@ -94,26 +123,29 @@ export class ReflectMemories extends Resource {
|
|
|
94
123
|
// never cite another user's memory.
|
|
95
124
|
if (!memoryMatchesReflectScope(record, { scope, tag, sinceDate }))
|
|
96
125
|
continue;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
126
|
+
if (record.lastReflected == null || record.lastReflected === "")
|
|
127
|
+
unreflectedSeen++;
|
|
128
|
+
const { embedding: _embedding, ...rest } = record;
|
|
129
|
+
considerForOldestUnreflectedCap(memories, rest, maxN);
|
|
130
|
+
}
|
|
131
|
+
if (isRemAbortRequested(process.env, existsSync, REM_PAUSE_FLAG)) {
|
|
132
|
+
return new Response(JSON.stringify({
|
|
133
|
+
error: "rem_aborted",
|
|
134
|
+
detail: "REM distillation aborted (pause sentinel or FLAIR_REM_PAUSE=1). Resume with: flair rem resume",
|
|
135
|
+
}), { status: 503 });
|
|
101
136
|
}
|
|
102
|
-
memories.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
103
137
|
// Collect tags present in source memories
|
|
104
138
|
const tagSet = new Set();
|
|
105
139
|
for (const m of memories) {
|
|
106
140
|
for (const t of m.tags ?? [])
|
|
107
141
|
tagSet.add(t);
|
|
108
142
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
const promptInputs = memories.map((m) => ({ id: m.id, createdAt: m.createdAt, content: m.content }));
|
|
143
|
+
const promptInputs = memories.map((m) => ({
|
|
144
|
+
id: m.id,
|
|
145
|
+
createdAt: m.createdAt,
|
|
146
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
147
|
+
}));
|
|
148
|
+
const gatherMeta = { gathered: memories.length, unreflected: unreflectedSeen, maxMemories: maxN };
|
|
117
149
|
if (!execute) {
|
|
118
150
|
const prompt = buildReflectionPrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
|
|
119
151
|
return {
|
|
@@ -121,6 +153,17 @@ export class ReflectMemories extends Resource {
|
|
|
121
153
|
prompt,
|
|
122
154
|
suggestedTags: [...tagSet].slice(0, 20),
|
|
123
155
|
count: memories.length,
|
|
156
|
+
...gatherMeta,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// #1515: empty gather → no generate() call. A fully-reflected (or empty)
|
|
160
|
+
// set must not spend a model turn or hold the main thread.
|
|
161
|
+
if (promptInputs.length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
candidates: [],
|
|
164
|
+
count: 0,
|
|
165
|
+
model: process.env.FLAIR_REM_MODEL || "default",
|
|
166
|
+
...gatherMeta,
|
|
124
167
|
};
|
|
125
168
|
}
|
|
126
169
|
// ── execute mode (spec §3A) ─────────────────────────────────────────────
|
|
@@ -184,7 +227,12 @@ export class ReflectMemories extends Resource {
|
|
|
184
227
|
}
|
|
185
228
|
// Dedup against this agent's existing pending candidates (spec §3A item 4).
|
|
186
229
|
const existingPendingClaims = [];
|
|
230
|
+
yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
|
|
187
231
|
for await (const c of databases.flair.MemoryCandidate.search({})) {
|
|
232
|
+
if (performance.now() >= yieldAt) {
|
|
233
|
+
await yieldToRequests();
|
|
234
|
+
yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
|
|
235
|
+
}
|
|
188
236
|
if (c.agentId !== agentId)
|
|
189
237
|
continue;
|
|
190
238
|
if (c.status !== "pending")
|
|
@@ -225,6 +273,15 @@ export class ReflectMemories extends Resource {
|
|
|
225
273
|
await databases.flair.MemoryCandidate.put(row);
|
|
226
274
|
staged.push(row);
|
|
227
275
|
}
|
|
276
|
+
// Stamp after a successful generate on execute runs only. Prompt-only
|
|
277
|
+
// and 502/503/abort leave lastReflected unset so the next night retries
|
|
278
|
+
// the same sources instead of permanently skipping them (#1515 Bugbot).
|
|
279
|
+
if (shouldStampLastReflected({ execute, generateSucceeded: true })) {
|
|
280
|
+
const now = new Date().toISOString();
|
|
281
|
+
for (const memory of memories) {
|
|
282
|
+
patchRecordSilent(databases.flair.Memory, memory.id, { lastReflected: now });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
228
285
|
// Response omits rationalePrompt (spec §3A item 5: "no prompt field") —
|
|
229
286
|
// it's identical across every row in this batch and already persisted
|
|
230
287
|
// for audit on the MemoryCandidate row itself; echoing it back per
|
|
@@ -235,6 +292,7 @@ export class ReflectMemories extends Resource {
|
|
|
235
292
|
candidates: responseCandidates,
|
|
236
293
|
count: responseCandidates.length,
|
|
237
294
|
model: resolvedModel,
|
|
295
|
+
...gatherMeta,
|
|
238
296
|
// flair#1257 slice 3: continuity-run observability — how many candidates
|
|
239
297
|
// the stale-intent post-filter dropped (0 for non-continuity runs).
|
|
240
298
|
...(isContinuityRun ? { droppedStaleIntent: staleIntentResult.droppedStaleIntent.length } : {}),
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message.ts — Flair Relay S1 resource surface (point-to-point, single host).
|
|
3
|
+
*
|
|
4
|
+
* A signed, principal-addressed, durably-queued message with an explicit ack
|
|
5
|
+
* and a VISIBLE dead-letter — a DIFFERENT primitive from memory and OrgEvent
|
|
6
|
+
* (whose expiresAt is a silent dead-letter). See relay-ops.ts for the
|
|
7
|
+
* orchestration and relay-lib.ts for the pure primitives; this file is the thin
|
|
8
|
+
* Harper adapter that resolves auth and injects the real table accessors.
|
|
9
|
+
*
|
|
10
|
+
* Auth (self-enforced, mirroring OrgEvent/MemoryGrant now that the global gate
|
|
11
|
+
* is non-rejecting): reads gated to verified agents; every write path resolves
|
|
12
|
+
* the three-way verdict and fails closed on anonymous. Direct REST mutation of
|
|
13
|
+
* the table (PUT/DELETE) is admin/internal only — agents SEND via post() and
|
|
14
|
+
* ACK via MessageAck, never by writing state fields directly. relay-ops' own
|
|
15
|
+
* writes use the static table accessor and bypass these instance gates (the
|
|
16
|
+
* same raw-put seam Federation.ts relies on).
|
|
17
|
+
*/
|
|
18
|
+
import { Resource, databases } from "harper";
|
|
19
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
20
|
+
import { makeAuthGate, makeScopedSearch, FORBIDDEN, UNAUTH, NOT_FOUND } from "./record-type-kit.js";
|
|
21
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
22
|
+
import { relaySend, relayInbox, relayConsume, relayDeadLetters, relaySweepDeadlines, } from "./relay-ops.js";
|
|
23
|
+
function relayDeps() {
|
|
24
|
+
return {
|
|
25
|
+
messages: databases.flair.Message,
|
|
26
|
+
agents: databases.flair.Agent,
|
|
27
|
+
resolveOrg: localInstanceId,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function ctxOf(self) {
|
|
31
|
+
return self.getContext?.();
|
|
32
|
+
}
|
|
33
|
+
function pathId(pathInfo) {
|
|
34
|
+
const id = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : undefined) ??
|
|
35
|
+
(typeof pathInfo === "string" ? pathInfo : undefined);
|
|
36
|
+
return id ? String(id) : undefined;
|
|
37
|
+
}
|
|
38
|
+
// ─── Message table resource — post() sends a message ────────────────────────
|
|
39
|
+
const messageAuthGate = makeAuthGate();
|
|
40
|
+
// P0-1 collection read-scope: a non-admin agent's collection read is limited to
|
|
41
|
+
// the messages it is a PARTY to — `from === self` OR `to === self`. Composed as
|
|
42
|
+
// the OUTERMOST `and` block (makeScopedSearch) so a caller-supplied
|
|
43
|
+
// `operator: "or"` cannot boolean-inject past the scope — the exact
|
|
44
|
+
// injection-safe shape Memory.search() uses. Without this, super.get() on a
|
|
45
|
+
// collection returns EVERY row in the table to any verified agent (the
|
|
46
|
+
// cross-principal read leak Kern P0-1 flagged). Admin/internal are unfiltered.
|
|
47
|
+
const messageScopedSearch = makeScopedSearch(async (agentId) => ({
|
|
48
|
+
condition: {
|
|
49
|
+
operator: "or",
|
|
50
|
+
conditions: [
|
|
51
|
+
{ attribute: "from", comparator: "equals", value: agentId },
|
|
52
|
+
{ attribute: "to", comparator: "equals", value: agentId },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
isAllowed: (r) => !!r && (r.from === agentId || r.to === agentId),
|
|
56
|
+
}));
|
|
57
|
+
export class Message extends databases.flair.Message {
|
|
58
|
+
allowRead() {
|
|
59
|
+
return messageAuthGate.call(this);
|
|
60
|
+
}
|
|
61
|
+
/** Send (POST) self-authorizes for any verified agent: Harper authorizes
|
|
62
|
+
* BEFORE post() runs, so without this a de-elevated flair_agent 403s on the
|
|
63
|
+
* create before relaySend is ever reached (Kern P0-2). Per-send ownership
|
|
64
|
+
* (no-forge `from`, signature) is enforced inside relaySend. */
|
|
65
|
+
async allowCreate() {
|
|
66
|
+
return allowVerified(ctxOf(this));
|
|
67
|
+
}
|
|
68
|
+
async post(content) {
|
|
69
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
70
|
+
return relaySend(relayDeps(), auth, content ?? {});
|
|
71
|
+
}
|
|
72
|
+
/** Scope a non-admin agent's collection read to the messages it is a party
|
|
73
|
+
* to (from/to === self); admin/internal unfiltered; anonymous denied. Reached
|
|
74
|
+
* via get()'s collection branch, the same way Memory.get delegates collection
|
|
75
|
+
* reads to Memory.search. */
|
|
76
|
+
async search(query) {
|
|
77
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
78
|
+
if (auth.kind === "anonymous")
|
|
79
|
+
return UNAUTH();
|
|
80
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
|
|
81
|
+
return super.search(query);
|
|
82
|
+
return messageScopedSearch(auth.agentId, query, (q) => super.search(q));
|
|
83
|
+
}
|
|
84
|
+
/** By-id read is scoped to the two parties (from/to); anyone else gets 404,
|
|
85
|
+
* so ids can't be enumerated. Collection reads route to the party-scoped
|
|
86
|
+
* search() above (P0-1) — NOT super.get, which returns the whole table. */
|
|
87
|
+
async get(target) {
|
|
88
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
89
|
+
return this.search(target);
|
|
90
|
+
}
|
|
91
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
92
|
+
if (auth.kind === "anonymous")
|
|
93
|
+
return NOT_FOUND();
|
|
94
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
|
|
95
|
+
return super.get(target);
|
|
96
|
+
const record = await super.get(target);
|
|
97
|
+
if (!record)
|
|
98
|
+
return NOT_FOUND();
|
|
99
|
+
if (record.from !== auth.agentId && record.to !== auth.agentId)
|
|
100
|
+
return NOT_FOUND();
|
|
101
|
+
return record;
|
|
102
|
+
}
|
|
103
|
+
/** Direct table writes are admin/internal only — see the file header. */
|
|
104
|
+
async put(content, context) {
|
|
105
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
106
|
+
if (auth.kind === "anonymous")
|
|
107
|
+
return UNAUTH();
|
|
108
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
109
|
+
return FORBIDDEN("forbidden: send via POST and ack via MessageAck; direct writes are not permitted");
|
|
110
|
+
}
|
|
111
|
+
return super.put(content, context);
|
|
112
|
+
}
|
|
113
|
+
/** PATCH is a distinct Harper verb (Resource.patch → type:update → TableResource.patch
|
|
114
|
+
* runs update()+save(), NEVER Message.put()), so without this override a de-elevated
|
|
115
|
+
* agent with an `update` grant could mutate ANY message — set state, rewrite
|
|
116
|
+
* body/from/to on a row it is not a party to — bypassing put()'s guard and
|
|
117
|
+
* relayConsume's recipient-only check (Kern P0). The `update:false` grant now closes
|
|
118
|
+
* this at the platform gate; this mirrors put()'s guard as defense-in-depth for the
|
|
119
|
+
* verb surface (admin/internal only). Agents ack via MessageAck, never a direct PATCH. */
|
|
120
|
+
async patch(content, context) {
|
|
121
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
122
|
+
if (auth.kind === "anonymous")
|
|
123
|
+
return UNAUTH();
|
|
124
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
125
|
+
return FORBIDDEN("forbidden: ack via MessageAck; direct writes are not permitted");
|
|
126
|
+
}
|
|
127
|
+
return super.patch(content, context);
|
|
128
|
+
}
|
|
129
|
+
async delete(id, context) {
|
|
130
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
131
|
+
if (auth.kind === "anonymous")
|
|
132
|
+
return UNAUTH();
|
|
133
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
134
|
+
return FORBIDDEN("forbidden: direct deletes are not permitted");
|
|
135
|
+
}
|
|
136
|
+
return super.delete(id, context);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// ─── /MessageInbox/{to}? — the caller's unconsumed inbox, sorted by seq ──────
|
|
140
|
+
export class MessageInbox extends Resource {
|
|
141
|
+
async allowRead() {
|
|
142
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
143
|
+
return auth.kind !== "anonymous";
|
|
144
|
+
}
|
|
145
|
+
async get(pathInfo) {
|
|
146
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
147
|
+
return relayInbox(relayDeps(), auth, pathId(pathInfo));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ─── /MessageAck (POST {id}) — recipient acks a message ────────────────────
|
|
151
|
+
export class MessageAck extends Resource {
|
|
152
|
+
async allowRead() {
|
|
153
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
154
|
+
return auth.kind !== "anonymous";
|
|
155
|
+
}
|
|
156
|
+
/** Ack (POST) self-authorizes for any verified agent: a bare Resource
|
|
157
|
+
* subclass defaults allowCreate to super_user, which 403s every de-elevated
|
|
158
|
+
* flair_agent — the ack, the core primitive, would be admin-only as shipped
|
|
159
|
+
* (Kern P0-2). Recipient-only ownership is enforced inside relayConsume. */
|
|
160
|
+
async allowCreate() {
|
|
161
|
+
return allowVerified(ctxOf(this));
|
|
162
|
+
}
|
|
163
|
+
async post(content) {
|
|
164
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
165
|
+
const id = content && typeof content === "object" ? String(content.id ?? "") : "";
|
|
166
|
+
return relayConsume(relayDeps(), auth, id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ─── /MessageDeadLetter/{from}? — the sender's visible failures ──────────────
|
|
170
|
+
export class MessageDeadLetter extends Resource {
|
|
171
|
+
async allowRead() {
|
|
172
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
173
|
+
return auth.kind !== "anonymous";
|
|
174
|
+
}
|
|
175
|
+
async get(pathInfo) {
|
|
176
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
177
|
+
return relayDeadLetters(relayDeps(), auth, pathId(pathInfo));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ─── /MessageSweep (POST) — admin/internal deadline sweep ───────────────────
|
|
181
|
+
export class MessageSweep extends Resource {
|
|
182
|
+
async allowRead() {
|
|
183
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
184
|
+
return auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin);
|
|
185
|
+
}
|
|
186
|
+
async post() {
|
|
187
|
+
const auth = await resolveAgentAuth(ctxOf(this));
|
|
188
|
+
return relaySweepDeadlines(relayDeps(), auth);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { databases } from "harper";
|
|
14
14
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
15
|
+
import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
|
|
15
16
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
16
17
|
import { makeAuthGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
17
18
|
import { RECORD_TYPES } from "./record-types.js";
|
|
@@ -55,7 +56,18 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
55
56
|
// Harper 5: table resources use put() for create/upsert (post() removed).
|
|
56
57
|
return databases.flair.OrgEvent.put(content);
|
|
57
58
|
}
|
|
59
|
+
// PATCH routes past put(), so authorId immutability is enforced on both verbs
|
|
60
|
+
// via the one shared delegate.
|
|
61
|
+
async patch(content, query) {
|
|
62
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "authorId");
|
|
63
|
+
if (denial)
|
|
64
|
+
return denial;
|
|
65
|
+
return super.patch(content, query);
|
|
66
|
+
}
|
|
58
67
|
async put(content) {
|
|
68
|
+
const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "authorId");
|
|
69
|
+
if (__ownerDenial)
|
|
70
|
+
return __ownerDenial;
|
|
59
71
|
const auth = await this._auth();
|
|
60
72
|
if (auth.kind === "anonymous")
|
|
61
73
|
return UNAUTH();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Resource, databases } from "harper";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
4
|
+
import { Memory } from "./Memory.js";
|
|
5
|
+
import { MemoryCandidate } from "./MemoryCandidate.js";
|
|
6
|
+
import { stampMemoryPromotionIsolated } from "./promotion-stamp.js";
|
|
7
|
+
import { derivePromotedTags, derivePromotedVisibility, validateHumanReviewerId } from "../src/rem/promote-policy.js";
|
|
8
|
+
const error = (status, message) => new Response(JSON.stringify({ error: message }), {
|
|
9
|
+
status, headers: { "content-type": "application/json" },
|
|
10
|
+
});
|
|
11
|
+
/** Human/agent review of a pending candidate. The request names the decision;
|
|
12
|
+
* content, owner, lineage and the resulting verdict come from stored state. */
|
|
13
|
+
export class PromoteMemoryCandidate extends Resource {
|
|
14
|
+
async allowCreate() {
|
|
15
|
+
return (await resolveAgentAuth(this.getContext?.())).kind !== "anonymous";
|
|
16
|
+
}
|
|
17
|
+
async post(data) {
|
|
18
|
+
const ctx = this.getContext?.();
|
|
19
|
+
const auth = await resolveAgentAuth(ctx);
|
|
20
|
+
if (auth.kind === "anonymous")
|
|
21
|
+
return error(401, "authentication required");
|
|
22
|
+
if (typeof data?.candidateId !== "string" || !data.candidateId)
|
|
23
|
+
return error(400, "candidateId required");
|
|
24
|
+
if (typeof data.rationale !== "string" || !data.rationale.trim())
|
|
25
|
+
return error(400, "rationale required");
|
|
26
|
+
const candidate = await MemoryCandidate.get(data.candidateId, ctx);
|
|
27
|
+
if (!candidate || candidate instanceof Response)
|
|
28
|
+
return error(404, "candidate not found");
|
|
29
|
+
if (auth.kind === "agent" && !auth.isAdmin && candidate.agentId !== auth.agentId)
|
|
30
|
+
return error(403, "cannot promote another agent's candidate");
|
|
31
|
+
if (candidate.status !== "pending")
|
|
32
|
+
return error(409, "candidate is not pending");
|
|
33
|
+
const actorId = auth.kind === "agent" ? auth.agentId : "admin";
|
|
34
|
+
const reviewerId = data.reviewerId ?? actorId;
|
|
35
|
+
if (typeof reviewerId !== "string" || !reviewerId.trim())
|
|
36
|
+
return error(400, "reviewerId required");
|
|
37
|
+
if (auth.kind === "agent" && !auth.isAdmin && reviewerId !== actorId)
|
|
38
|
+
return error(403, "cannot impersonate another reviewer");
|
|
39
|
+
const reviewerError = validateHumanReviewerId(reviewerId);
|
|
40
|
+
if (reviewerError)
|
|
41
|
+
return error(400, reviewerError);
|
|
42
|
+
const sourceFetches = [];
|
|
43
|
+
const scopeTag = typeof candidate.scopeTag === "string" && candidate.scopeTag ? candidate.scopeTag : undefined;
|
|
44
|
+
if (!scopeTag) {
|
|
45
|
+
for (const id of Array.isArray(candidate.sourceMemoryIds) ? candidate.sourceMemoryIds : []) {
|
|
46
|
+
try {
|
|
47
|
+
const memory = await Memory.get(String(id), ctx);
|
|
48
|
+
sourceFetches.push(memory && !(memory instanceof Response)
|
|
49
|
+
? { ok: true, tags: Array.isArray(memory.tags) ? memory.tags : [] } : { ok: false });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
sourceFetches.push({ ok: false });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const tags = derivePromotedTags(candidate.id, sourceFetches, scopeTag);
|
|
57
|
+
if (!tags.ok)
|
|
58
|
+
return error(400, tags.reason);
|
|
59
|
+
const visibility = derivePromotedVisibility(candidate);
|
|
60
|
+
const decidedAt = new Date().toISOString();
|
|
61
|
+
const memoryId = `${candidate.agentId}-promoted-${randomUUID()}`;
|
|
62
|
+
const written = await Memory.put({
|
|
63
|
+
id: memoryId, agentId: candidate.agentId, content: candidate.claim, durability: "persistent",
|
|
64
|
+
...(visibility ? { visibility } : {}), tags: tags.tags,
|
|
65
|
+
derivedFrom: candidate.sourceMemoryIds ?? [], createdAt: decidedAt,
|
|
66
|
+
}, ctx);
|
|
67
|
+
if (written instanceof Response && !written.ok)
|
|
68
|
+
return written;
|
|
69
|
+
await stampMemoryPromotionIsolated(memoryId, reviewerId, decidedAt);
|
|
70
|
+
await databases.flair.MemoryCandidate.put({
|
|
71
|
+
...candidate, status: "promoted", target: "memory", reviewerId,
|
|
72
|
+
reviewRationale: data.rationale, decidedAt,
|
|
73
|
+
});
|
|
74
|
+
return { memoryId, candidateId: candidate.id, reviewerId, decidedAt };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A generic "record that a memory was actually used" surface: an agent that
|
|
5
5
|
* grounded an answer or decision on a recalled memory reports it here.
|
|
6
6
|
* Distinct from — and NEVER wired to — retrieval: `Memory.retrievalCount`
|
|
7
|
-
* (bumped on every SemanticSearch hit, resources/
|
|
7
|
+
* (bumped on every SemanticSearch hit, resources/hit-tracking.ts) is
|
|
8
8
|
* the WEAK, self-reinforcing signal root-caused in flair#623 ("a search hit
|
|
9
9
|
* counted as usage"); `Memory.usageCount` (this endpoint's only writer) is
|
|
10
10
|
* the STRONG signal driving `usageBoost` in resources/scoring.ts, which
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { databases } from "harper";
|
|
2
2
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
|
|
3
4
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
5
|
import { localInstanceId } from "./instance-identity.js";
|
|
5
6
|
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
@@ -120,7 +121,18 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
120
121
|
* HTTP caller and a true internal call, so an internal caller would have
|
|
121
122
|
* been wrongly 401'd too. resolveAgentAuth distinguishes the two.
|
|
122
123
|
*/
|
|
124
|
+
// PATCH routes past put(), so agentId immutability is enforced on both verbs
|
|
125
|
+
// via the one shared delegate.
|
|
126
|
+
async patch(content, query) {
|
|
127
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
128
|
+
if (denial)
|
|
129
|
+
return denial;
|
|
130
|
+
return super.patch(content, query);
|
|
131
|
+
}
|
|
123
132
|
async put(content) {
|
|
133
|
+
const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
134
|
+
if (__ownerDenial)
|
|
135
|
+
return __ownerDenial;
|
|
124
136
|
const ctx = this.getContext?.();
|
|
125
137
|
const auth = await resolveAgentAuth(ctx);
|
|
126
138
|
if (auth.kind === "anonymous") {
|