@tpsdev-ai/flair 0.16.1 → 0.18.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/dist/resources/AdminMemory.js +6 -1
- package/dist/resources/AgentSeed.js +8 -1
- package/dist/resources/IngestEvents.js +7 -1
- package/dist/resources/Memory.js +312 -32
- package/dist/resources/MemoryBootstrap.js +35 -0
- package/dist/resources/OAuth.js +30 -4
- package/dist/resources/OrgEventCatchup.js +14 -6
- package/dist/resources/SemanticSearch.js +41 -3
- package/dist/resources/WorkspaceLatest.js +15 -6
- package/dist/resources/dedup.js +62 -0
- package/dist/resources/health.js +17 -0
- package/dist/resources/mcp-handler.js +248 -0
- package/dist/resources/mcp-oauth-flag.js +84 -0
- package/dist/resources/mcp-oauth.js +84 -0
- package/dist/resources/mcp-tools.js +417 -0
- package/dist/resources/memory-bootstrap-lib.js +42 -0
- package/dist/resources/rerank-provider.js +361 -0
- package/package.json +3 -1
|
@@ -22,7 +22,12 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
|
22
22
|
*/
|
|
23
23
|
export class AdminMemory extends Resource {
|
|
24
24
|
async get() {
|
|
25
|
-
|
|
25
|
+
// Harper v5 does not populate this.request on Resource subclasses —
|
|
26
|
+
// getContext() is the only reliable path (ops-sal4: the previous
|
|
27
|
+
// `(this as any).request` read was always undefined, so query params
|
|
28
|
+
// (?id=, ?q=, ?subject=, ?limit=) were always ignored).
|
|
29
|
+
const ctx = this.getContext?.();
|
|
30
|
+
const request = ctx?.request ?? ctx;
|
|
26
31
|
const url = new URL(request?.url ?? "http://localhost", "http://localhost");
|
|
27
32
|
const id = url.searchParams.get("id") ?? "";
|
|
28
33
|
if (id) {
|
|
@@ -39,7 +39,14 @@ export class AgentSeed extends Resource {
|
|
|
39
39
|
return allowAdmin(this.getContext?.());
|
|
40
40
|
}
|
|
41
41
|
async post(data) {
|
|
42
|
-
|
|
42
|
+
// Harper v5 does not populate this.request on Resource subclasses —
|
|
43
|
+
// getContext() is the only reliable path (ops-sal4: the previous
|
|
44
|
+
// `(this as any).request` read was always undefined, so actorId was always
|
|
45
|
+
// undefined and this belt-and-suspenders check fail-closed every request,
|
|
46
|
+
// even from a real admin already verified by allowCreate()).
|
|
47
|
+
const ctx = this.getContext?.();
|
|
48
|
+
const request = ctx?.request ?? ctx;
|
|
49
|
+
const actorId = request?.tpsAgent;
|
|
43
50
|
if (!actorId || !(await isAdmin(actorId))) {
|
|
44
51
|
return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403 });
|
|
45
52
|
}
|
|
@@ -63,7 +63,13 @@ export class IngestEvents extends Resource {
|
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
async post(body, context) {
|
|
66
|
-
|
|
66
|
+
// Harper v5 does not populate this.request on Resource subclasses —
|
|
67
|
+
// getContext() is the only reliable path (ops-sal4: the previous
|
|
68
|
+
// `(this as any).request` read was always undefined, so authHeader was
|
|
69
|
+
// always undefined and every request 401'd before reaching the office
|
|
70
|
+
// Ed25519 signature check below).
|
|
71
|
+
const ctx = this.getContext?.();
|
|
72
|
+
const request = ctx?.request ?? ctx;
|
|
67
73
|
const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
|
|
68
74
|
// Parse and validate body
|
|
69
75
|
let payload;
|
package/dist/resources/Memory.js
CHANGED
|
@@ -4,6 +4,232 @@ import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
|
+
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, isConservativeMatch, } from "./dedup.js";
|
|
8
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
9
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
10
|
+
/**
|
|
11
|
+
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
12
|
+
*
|
|
13
|
+
* NEVER SUPPRESSES A WRITE. This gate only computes a SIGNAL — the caller
|
|
14
|
+
* (Memory.post / Memory.put) always proceeds to write the new record. When a
|
|
15
|
+
* conservative match is found, the signal is attached to the RESPONSE only
|
|
16
|
+
* (deduplicated/matchedId/matchConfidence). There must be no code path where
|
|
17
|
+
* finding a match causes the write to be skipped: that was the #526 bug (two
|
|
18
|
+
* topically-close but DISTINCT findings — one about replication
|
|
19
|
+
* route-directionality, one about DDL/schema replication — and the SECOND was
|
|
20
|
+
* silently dropped because the old client-side gate returned the existing
|
|
21
|
+
* record instead of writing).
|
|
22
|
+
*
|
|
23
|
+
* Conservative match = raw cosine >= cosineThreshold AND Jaccard token-overlap
|
|
24
|
+
* >= lexicalThreshold, checked ONLY against the SINGLE top-cosine candidate
|
|
25
|
+
* (scoped to the same agentId as the write — never cross-agent). If that one
|
|
26
|
+
* candidate fails either gate, there is no match; we do not fall back to the
|
|
27
|
+
* 2nd-most-similar candidate.
|
|
28
|
+
*
|
|
29
|
+
* Previously this lived client-side (packages/flair-client/src/client.ts,
|
|
30
|
+
* pre-fix) and only ran for callers that opted into `dedup:true` over HTTP
|
|
31
|
+
* PUT — the Model-2 /mcp handler (resources/mcp-tools.ts), which calls
|
|
32
|
+
* Memory.post() directly, got ZERO dedup checking. Moving the gate here makes
|
|
33
|
+
* it apply uniformly regardless of transport (HTTP PUT vs in-process post()).
|
|
34
|
+
*
|
|
35
|
+
* NOTE on HTTP verbs: the Memory schema only exposes PUT over HTTP (a raw
|
|
36
|
+
* HTTP POST /Memory returns "Memory does not have a post method implemented"
|
|
37
|
+
* — see src/cli.ts's `flair test` command and commit 2fa6d22 / ops-pj5).
|
|
38
|
+
* `Memory.post()` IS reachable, but only via an in-process resource
|
|
39
|
+
* instantiation (as resources/mcp-tools.ts does) — never via the real HTTP
|
|
40
|
+
* POST route. Because flair-client's write() (used by flair-mcp, the CLI, and
|
|
41
|
+
* every other integration package) issues an HTTP PUT, the actual
|
|
42
|
+
* field-observed bug (#526) flows through Memory.put(), not Memory.post().
|
|
43
|
+
* The gate below is therefore a SHARED helper invoked from both post() and
|
|
44
|
+
* put() — anchored in the same place the design calls out (Memory.post), but
|
|
45
|
+
* wired into put() too so the write path real callers actually use is
|
|
46
|
+
* protected. See memory-integrity-fix report for the full writeup of this
|
|
47
|
+
* deviation from a literal "gate lives only in Memory.post" reading.
|
|
48
|
+
*/
|
|
49
|
+
async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
|
|
50
|
+
if (!agentId || !embedding || embedding.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
const query = {
|
|
54
|
+
sort: { attribute: "embedding", target: embedding, distance: "cosine" },
|
|
55
|
+
conditions: [
|
|
56
|
+
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
57
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
58
|
+
],
|
|
59
|
+
select: ["id", "content", "$distance"],
|
|
60
|
+
limit: 1,
|
|
61
|
+
};
|
|
62
|
+
let top = null;
|
|
63
|
+
// Detach ctx.transaction around this search — same rationale as
|
|
64
|
+
// Memory.search()/SemanticSearch.ts: a drained search generator can leave
|
|
65
|
+
// a CLOSED transaction in ctx's chain that the subsequent WRITE
|
|
66
|
+
// (super.post/super.put, right after this gate runs) would otherwise
|
|
67
|
+
// inherit. Detaching here protects that write, not this read.
|
|
68
|
+
const results = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
69
|
+
for await (const record of results) {
|
|
70
|
+
top = record;
|
|
71
|
+
break; // single top-cosine candidate only — never fall back further
|
|
72
|
+
}
|
|
73
|
+
if (!top)
|
|
74
|
+
return null;
|
|
75
|
+
const cosine = 1 - (top.$distance ?? 1);
|
|
76
|
+
const confidence = computeMatchConfidence(contentText, top.content, cosine);
|
|
77
|
+
if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return { matchedId: top.id, cosine: confidence.cosine, lexical: confidence.lexical };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Dedup-check failures (embedding engine down, search error, etc.) must
|
|
84
|
+
// NEVER block or alter the write — treat as "no match found".
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Run the dedup gate for a create-shaped write. Mutates `content.embedding` /
|
|
90
|
+
* `content.embeddingModel` when it computes a fresh embedding, so the
|
|
91
|
+
* existing "generate embedding if missing" step later in post()/put() reuses
|
|
92
|
+
* it instead of recomputing. Always strips the client-forwarded hint fields
|
|
93
|
+
* (dedup / dedupThreshold / lexicalThreshold) so they never persist onto the
|
|
94
|
+
* stored record — they are passthrough tuning hints, not schema fields.
|
|
95
|
+
*
|
|
96
|
+
* Returns the match signal, or null (no match / gate not applicable).
|
|
97
|
+
*/
|
|
98
|
+
async function runDedupGate(ctx, content) {
|
|
99
|
+
const cosineThreshold = typeof content.dedupThreshold === "number" ? content.dedupThreshold : DEDUP_COSINE_THRESHOLD_DEFAULT;
|
|
100
|
+
const lexicalThreshold = typeof content.lexicalThreshold === "number" ? content.lexicalThreshold : DEDUP_LEXICAL_THRESHOLD_DEFAULT;
|
|
101
|
+
delete content.dedup;
|
|
102
|
+
delete content.dedupThreshold;
|
|
103
|
+
delete content.lexicalThreshold;
|
|
104
|
+
if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
let embedding = Array.isArray(content.embedding) ? content.embedding : null;
|
|
108
|
+
if (!embedding) {
|
|
109
|
+
try {
|
|
110
|
+
embedding = await getEmbedding(content.content);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
embedding = null;
|
|
114
|
+
}
|
|
115
|
+
if (embedding) {
|
|
116
|
+
content.embedding = embedding;
|
|
117
|
+
content.embeddingModel = getModelId();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!embedding)
|
|
121
|
+
return null;
|
|
122
|
+
return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
|
|
123
|
+
}
|
|
124
|
+
/** Build the final write response: always `written: true`, always includes
|
|
125
|
+
* `id`, and layers the dedup collision signal on top when present. Never a
|
|
126
|
+
* code path where a match suppresses these base fields. */
|
|
127
|
+
function buildWriteResponse(content, result, dedupMatch) {
|
|
128
|
+
const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
|
|
129
|
+
const response = {
|
|
130
|
+
id: content.id,
|
|
131
|
+
...base,
|
|
132
|
+
written: true,
|
|
133
|
+
deduplicated: !!dedupMatch,
|
|
134
|
+
};
|
|
135
|
+
if (dedupMatch) {
|
|
136
|
+
response.matchedId = dedupMatch.matchedId;
|
|
137
|
+
response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
|
|
138
|
+
}
|
|
139
|
+
return response;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Read-modify-write close of a superseded record, with the SAME transaction
|
|
143
|
+
* detachment discipline as findConservativeDedupMatch (each discrete Harper
|
|
144
|
+
* call individually wrapped — see withDetachedTxn's doc for why a single
|
|
145
|
+
* wrap around a multi-await async function would not protect the later
|
|
146
|
+
* call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
|
|
147
|
+
* log it. Never called before the new record is already written.
|
|
148
|
+
*/
|
|
149
|
+
async function closeSupersededRecord(ctx, oldId, patch) {
|
|
150
|
+
const existing = await withDetachedTxn(ctx, () => databases.flair.Memory.get(oldId));
|
|
151
|
+
if (!existing) {
|
|
152
|
+
throw new Error(`supersede-close: record ${oldId} not found`);
|
|
153
|
+
}
|
|
154
|
+
await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
|
|
155
|
+
}
|
|
156
|
+
/** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
|
|
157
|
+
* pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
|
|
158
|
+
* here for the "write" scope that gates cross-agent supersede. */
|
|
159
|
+
async function hasWriteGrant(granteeId, ownerId) {
|
|
160
|
+
try {
|
|
161
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
162
|
+
conditions: [
|
|
163
|
+
{ attribute: "granteeId", comparator: "equals", value: granteeId },
|
|
164
|
+
{ attribute: "ownerId", comparator: "equals", value: ownerId },
|
|
165
|
+
],
|
|
166
|
+
})) {
|
|
167
|
+
if (grant.scope === "write")
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* MemoryGrant table not yet populated — no grant */
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Shared by post() AND put(): the Memory schema only exposes a working HTTP
|
|
178
|
+
* PUT route (a raw HTTP POST /Memory 404s with "Memory does not have a post
|
|
179
|
+
* method implemented" — see src/cli.ts's `flair test` command / commit
|
|
180
|
+
* 2fa6d22 / ops-pj5). Memory.post() IS reachable, but only via an in-process
|
|
181
|
+
* resource instantiation (resources/mcp-tools.ts does this). Since
|
|
182
|
+
* flair-client's write()/update() — used by flair-mcp, the CLI, and every
|
|
183
|
+
* other integration package — issue HTTP PUT, `supersedes` must be fully
|
|
184
|
+
* handled (validated, authorized, and closed) from BOTH entry points for the
|
|
185
|
+
* real-world write path to actually get the fix, not just the in-process one.
|
|
186
|
+
*
|
|
187
|
+
* Validates the `supersedes` field's shape and, for a cross-agent supersede,
|
|
188
|
+
* requires a "write" MemoryGrant from the target's owner (reuses the existing
|
|
189
|
+
* agent-auth/grant machinery — no parallel auth logic). Returns a Response to
|
|
190
|
+
* short-circuit with (400/403), or null to continue.
|
|
191
|
+
*/
|
|
192
|
+
async function validateAndAuthorizeSupersedes(content, auth) {
|
|
193
|
+
if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
|
|
194
|
+
return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
|
|
195
|
+
status: 400, headers: { "Content-Type": "application/json" },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (content.supersedes && auth.kind === "agent" && !auth.isAdmin) {
|
|
199
|
+
const target = await databases.flair.Memory.get(content.supersedes).catch(() => null);
|
|
200
|
+
if (target && target.agentId !== auth.agentId) {
|
|
201
|
+
if (!(await hasWriteGrant(auth.agentId, target.agentId))) {
|
|
202
|
+
return FORBIDDEN("forbidden: cannot supersede a memory owned by another agent without a write grant");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Close the superseded record — called AFTER the new record has already been
|
|
210
|
+
* written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
|
|
211
|
+
* two active records (recoverable), never a tombstoned-old-with-lost-new.
|
|
212
|
+
* Failure is logged (observable), never silently swallowed. No-op if
|
|
213
|
+
* `content.supersedes` is not set.
|
|
214
|
+
*/
|
|
215
|
+
async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
216
|
+
if (!content.supersedes)
|
|
217
|
+
return;
|
|
218
|
+
try {
|
|
219
|
+
await closeSupersededRecord(ctx, content.supersedes, {
|
|
220
|
+
validTo: content.validFrom ?? content.createdAt,
|
|
221
|
+
updatedAt: content.createdAt ?? content.updatedAt,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
// Constant format string + structured data: memory ids are agent-controlled,
|
|
226
|
+
// so interpolating them into console.error's format position (with a trailing
|
|
227
|
+
// `err` arg) would let an id containing %s/%o consume/hide the real error
|
|
228
|
+
// (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
|
|
229
|
+
console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
|
|
230
|
+
"(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
7
233
|
export class Memory extends databases.flair.Memory {
|
|
8
234
|
/**
|
|
9
235
|
* Override search() to scope collection GETs by authenticated agent.
|
|
@@ -78,20 +304,17 @@ export class Memory extends databases.flair.Memory {
|
|
|
78
304
|
// resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
|
|
79
305
|
// .username, which is the fallback "admin" super_user while de-elevation is
|
|
80
306
|
// dormant and would wrongly 403 every agent's own write. internal/admin → pass.
|
|
307
|
+
let auth;
|
|
81
308
|
{
|
|
82
|
-
|
|
309
|
+
auth = await resolveAgentAuth(ctx);
|
|
83
310
|
// Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
|
|
84
311
|
// upstream; with the non-rejecting gate, each write path self-enforces (same
|
|
85
312
|
// rule search() applies to reads).
|
|
86
313
|
if (auth.kind === "anonymous") {
|
|
87
|
-
return
|
|
88
|
-
status: 401, headers: { "Content-Type": "application/json" },
|
|
89
|
-
});
|
|
314
|
+
return UNAUTH();
|
|
90
315
|
}
|
|
91
316
|
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
92
|
-
return
|
|
93
|
-
status: 403, headers: { "Content-Type": "application/json" },
|
|
94
|
-
});
|
|
317
|
+
return FORBIDDEN("forbidden: cannot write memory owned by another agent");
|
|
95
318
|
}
|
|
96
319
|
}
|
|
97
320
|
content.durability ||= "standard";
|
|
@@ -111,23 +334,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
111
334
|
catch { }
|
|
112
335
|
}
|
|
113
336
|
}
|
|
114
|
-
// supersedes: optional reference to the ID of the memory this one
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
337
|
+
// supersedes: optional reference to the ID of the memory this one
|
|
338
|
+
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
339
|
+
// with put() — see validateAndAuthorizeSupersedes doc).
|
|
340
|
+
const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
|
|
341
|
+
if (supersedesError)
|
|
342
|
+
return supersedesError;
|
|
120
343
|
// Temporal validity: validFrom defaults to now, validTo left null for active facts.
|
|
121
|
-
// When a memory supersedes another, close the superseded memory's validity window.
|
|
122
344
|
if (!content.validFrom) {
|
|
123
345
|
content.validFrom = content.createdAt;
|
|
124
346
|
}
|
|
125
|
-
if (content.supersedes) {
|
|
126
|
-
patchRecord(databases.flair.Memory, content.supersedes, {
|
|
127
|
-
validTo: content.validFrom,
|
|
128
|
-
updatedAt: content.createdAt,
|
|
129
|
-
}).catch(() => { });
|
|
130
|
-
}
|
|
131
347
|
if (content.durability === "ephemeral" && !content.expiresAt) {
|
|
132
348
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
133
349
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
@@ -147,7 +363,22 @@ export class Memory extends databases.flair.Memory {
|
|
|
147
363
|
content._safetyFlags = safety.flags;
|
|
148
364
|
}
|
|
149
365
|
}
|
|
150
|
-
//
|
|
366
|
+
// Server-side conservative-duplicate gate (memory-integrity fix). A
|
|
367
|
+
// supersede write is an intentional version-link, not an ambiguous "is
|
|
368
|
+
// this a duplicate of something else" situation — bypass the gate for it
|
|
369
|
+
// (this also gives memory_update's preserveHistory mode dedup-bypass for
|
|
370
|
+
// free, without a separate flag). NEVER suppresses the write either way.
|
|
371
|
+
let dedupMatch = null;
|
|
372
|
+
if (!content.supersedes) {
|
|
373
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
delete content.dedup;
|
|
377
|
+
delete content.dedupThreshold;
|
|
378
|
+
delete content.lexicalThreshold;
|
|
379
|
+
}
|
|
380
|
+
// Generate embedding from content text (no-op if the dedup gate above
|
|
381
|
+
// already computed one for this content).
|
|
151
382
|
if (content.content && !content.embedding) {
|
|
152
383
|
const vec = await getEmbedding(content.content);
|
|
153
384
|
if (vec) {
|
|
@@ -155,7 +386,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
155
386
|
content.embeddingModel = getModelId();
|
|
156
387
|
}
|
|
157
388
|
}
|
|
158
|
-
|
|
389
|
+
// ── Write the new record FIRST ──────────────────────────────────────────
|
|
390
|
+
const result = await super.post(content);
|
|
391
|
+
// ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
|
|
392
|
+
// Write-new-BEFORE-close-old: the previous order (close-old via a fire-
|
|
393
|
+
// and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
|
|
394
|
+
// old record and then lose the new one if the write failed afterward.
|
|
395
|
+
// Now the safe failure state is two active records (recoverable), never
|
|
396
|
+
// a lost write — and the failure is logged, never silently swallowed.
|
|
397
|
+
await closeSupersededIfNeeded(ctx, content, "post");
|
|
398
|
+
return buildWriteResponse(content, result, dedupMatch);
|
|
159
399
|
}
|
|
160
400
|
async put(content) {
|
|
161
401
|
// Reindex migration bypass: admin-only escape hatch used by the
|
|
@@ -181,19 +421,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
181
421
|
// write memories it owns, via resolveAgentAuth (gate annotation), not
|
|
182
422
|
// context.user.username (the dormant-de-elevation fallback is "admin").
|
|
183
423
|
// The _reindex admin path above bypasses this.
|
|
424
|
+
const ctx = this.getContext?.();
|
|
425
|
+
let auth;
|
|
184
426
|
{
|
|
185
|
-
|
|
186
|
-
const auth = await resolveAgentAuth(octx);
|
|
427
|
+
auth = await resolveAgentAuth(ctx);
|
|
187
428
|
// Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
|
|
188
429
|
if (auth.kind === "anonymous") {
|
|
189
|
-
return
|
|
190
|
-
status: 401, headers: { "Content-Type": "application/json" },
|
|
191
|
-
});
|
|
430
|
+
return UNAUTH();
|
|
192
431
|
}
|
|
193
432
|
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
194
|
-
return
|
|
195
|
-
status: 403, headers: { "Content-Type": "application/json" },
|
|
196
|
-
});
|
|
433
|
+
return FORBIDDEN("forbidden: cannot write memory owned by another agent");
|
|
197
434
|
}
|
|
198
435
|
}
|
|
199
436
|
const now = new Date().toISOString();
|
|
@@ -201,6 +438,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
201
438
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
202
439
|
content.archived = content.archived ?? false;
|
|
203
440
|
content.createdAt = content.createdAt ?? now;
|
|
441
|
+
// supersedes: optional reference to the ID of the memory this one
|
|
442
|
+
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
443
|
+
// with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
|
|
444
|
+
// this too: it's the only HTTP-reachable create path).
|
|
445
|
+
const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
|
|
446
|
+
if (supersedesError)
|
|
447
|
+
return supersedesError;
|
|
448
|
+
if (content.supersedes && !content.validFrom) {
|
|
449
|
+
content.validFrom = content.createdAt;
|
|
450
|
+
}
|
|
204
451
|
// Content safety scan on updated content + summary (ops-i2jb).
|
|
205
452
|
if (content.content || content.summary) {
|
|
206
453
|
const safety = scanFields(content, ["content", "summary"]);
|
|
@@ -219,7 +466,36 @@ export class Memory extends databases.flair.Memory {
|
|
|
219
466
|
content._safetyFlags = null;
|
|
220
467
|
}
|
|
221
468
|
}
|
|
222
|
-
//
|
|
469
|
+
// Server-side conservative-duplicate gate (memory-integrity fix). PUT is
|
|
470
|
+
// an upsert: only run the gate for a FRESH create (target id does not yet
|
|
471
|
+
// exist) that is NOT a supersede-link write. An update of an EXISTING id
|
|
472
|
+
// (memory_update's default same-id overwrite path) is an intentional,
|
|
473
|
+
// explicit overwrite, and a supersede-link write is an intentional
|
|
474
|
+
// version-link — neither is an ambiguous "is this a duplicate of
|
|
475
|
+
// something else" write, so both are dedup-bypassed automatically, no
|
|
476
|
+
// separate flag needed. NEVER suppresses the write either way.
|
|
477
|
+
let dedupMatch = null;
|
|
478
|
+
if (content.supersedes) {
|
|
479
|
+
delete content.dedup;
|
|
480
|
+
delete content.dedupThreshold;
|
|
481
|
+
delete content.lexicalThreshold;
|
|
482
|
+
}
|
|
483
|
+
else if (content.id) {
|
|
484
|
+
const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
|
|
485
|
+
if (!preExisting) {
|
|
486
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
delete content.dedup;
|
|
490
|
+
delete content.dedupThreshold;
|
|
491
|
+
delete content.lexicalThreshold;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
496
|
+
}
|
|
497
|
+
// Re-generate embedding if content changed (no-op if the dedup gate above
|
|
498
|
+
// already computed one for this content).
|
|
223
499
|
if (content.content && !content.embedding) {
|
|
224
500
|
const vec = await getEmbedding(content.content);
|
|
225
501
|
if (vec) {
|
|
@@ -240,7 +516,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
240
516
|
if (content.promotionStatus === "approved") {
|
|
241
517
|
content.durability = "permanent";
|
|
242
518
|
}
|
|
243
|
-
|
|
519
|
+
// ── Write the new/updated record FIRST ──────────────────────────────────
|
|
520
|
+
const result = await super.put(content);
|
|
521
|
+
// ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
|
|
522
|
+
await closeSupersededIfNeeded(ctx, content, "put");
|
|
523
|
+
return buildWriteResponse(content, result, dedupMatch);
|
|
244
524
|
}
|
|
245
525
|
async delete(id) {
|
|
246
526
|
const record = await this.get(id);
|
|
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
|
|
|
2
2
|
import { allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { wrapUntrusted } from "./content-safety.js";
|
|
5
|
+
import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
5
6
|
/**
|
|
6
7
|
* POST /MemoryBootstrap
|
|
7
8
|
*
|
|
@@ -13,6 +14,9 @@ import { wrapUntrusted } from "./content-safety.js";
|
|
|
13
14
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
14
15
|
* 5. Relationship context (active relationships for mentioned entities)
|
|
15
16
|
* 6. Predicted context (based on channel/surface/subject hints)
|
|
17
|
+
* 7. Team roster (other active agents in this office + a search-first nudge —
|
|
18
|
+
* bootstrap only ever loads the caller's own memories, so this is the one
|
|
19
|
+
* place agents learn teammates' findings exist without a separate call)
|
|
16
20
|
*
|
|
17
21
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
18
22
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -80,6 +84,7 @@ export class BootstrapMemories extends Resource {
|
|
|
80
84
|
const sections = {
|
|
81
85
|
soul: [],
|
|
82
86
|
skills: [],
|
|
87
|
+
team: [],
|
|
83
88
|
permanent: [],
|
|
84
89
|
recent: [],
|
|
85
90
|
predicted: [],
|
|
@@ -172,6 +177,32 @@ export class BootstrapMemories extends Resource {
|
|
|
172
177
|
sections.skills.push(line);
|
|
173
178
|
}
|
|
174
179
|
}
|
|
180
|
+
// --- 1c. Team roster + cross-agent search nudge ---
|
|
181
|
+
// Bootstrap only ever loads the caller's OWN soul/memories (every query
|
|
182
|
+
// below filters record.agentId === agentId). Nothing here tells the agent
|
|
183
|
+
// that teammates exist or that their findings are one memory_search away
|
|
184
|
+
// — so agents never think to check before re-investigating something a
|
|
185
|
+
// teammate already solved. This section is fixed-cost (no query text to
|
|
186
|
+
// format per agent) so it's cheap enough to always include, not budgeted.
|
|
187
|
+
//
|
|
188
|
+
// Permissive kind/status checks are DELIBERATE: Agent.ts registration
|
|
189
|
+
// defaults both (`kind ||= "agent"`, `status ||= "active"`), so pre-1.0
|
|
190
|
+
// records missing either field are legacy agents/active — a strict
|
|
191
|
+
// `!== "agent"` check would silently drop them. Assumes single-tenant
|
|
192
|
+
// (one instance = one office); grant-filtered roster is the multi-tenant follow-up.
|
|
193
|
+
try {
|
|
194
|
+
const teammateIds = [];
|
|
195
|
+
for await (const record of databases.flair.Agent.search()) {
|
|
196
|
+
if (isTeammate(record, agentId))
|
|
197
|
+
teammateIds.push(record.id);
|
|
198
|
+
}
|
|
199
|
+
const line = formatTeamLine(teammateIds);
|
|
200
|
+
if (line)
|
|
201
|
+
sections.team.push(line);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Agent table may not exist in older / standalone deployments
|
|
205
|
+
}
|
|
175
206
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
176
207
|
const allMemories = [];
|
|
177
208
|
for await (const record of databases.flair.Memory.search()) {
|
|
@@ -379,6 +410,9 @@ export class BootstrapMemories extends Resource {
|
|
|
379
410
|
if (sections.skills.length > 0) {
|
|
380
411
|
parts.push("## Active Skills\n" + sections.skills.join("\n"));
|
|
381
412
|
}
|
|
413
|
+
if (sections.team.length > 0) {
|
|
414
|
+
parts.push("## Team\n" + sections.team.join("\n"));
|
|
415
|
+
}
|
|
382
416
|
if (sections.permanent.length > 0) {
|
|
383
417
|
parts.push("## Core Principles\n" + sections.permanent.join("\n"));
|
|
384
418
|
}
|
|
@@ -405,6 +439,7 @@ export class BootstrapMemories extends Resource {
|
|
|
405
439
|
sections: {
|
|
406
440
|
soul: sections.soul.length,
|
|
407
441
|
skills: sections.skills.length,
|
|
442
|
+
team: sections.team.length,
|
|
408
443
|
permanent: sections.permanent.length,
|
|
409
444
|
recent: sections.recent.length,
|
|
410
445
|
predicted: sections.predicted.length,
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { handleJwtBearerGrant } from "./XAA.js";
|
|
4
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
4
5
|
/**
|
|
5
6
|
* OAuth 2.1 Authorization Server for Flair.
|
|
6
7
|
*
|
|
@@ -121,7 +122,12 @@ export class OAuthAuthorize extends Resource {
|
|
|
121
122
|
async get() {
|
|
122
123
|
// In 1.0, this returns a simple HTML consent page.
|
|
123
124
|
// The user (Nathan) approves or denies, which POSTs back.
|
|
124
|
-
|
|
125
|
+
// Harper v5 does not populate this.request on Resource subclasses —
|
|
126
|
+
// getContext() is the only reliable path (ops-sal4: the previous
|
|
127
|
+
// `(this as any).request` read was always undefined, so query params were
|
|
128
|
+
// always empty).
|
|
129
|
+
const ctx = this.getContext?.();
|
|
130
|
+
const request = ctx?.request ?? ctx;
|
|
125
131
|
const url = new URL(request?.url ?? "http://localhost", "http://localhost");
|
|
126
132
|
const clientId = url.searchParams.get("client_id") ?? "";
|
|
127
133
|
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
|
@@ -193,9 +199,29 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
193
199
|
const params = new URLSearchParams({ error: "access_denied", state });
|
|
194
200
|
return Response.redirect(`${redirectUri}?${params}`, 302);
|
|
195
201
|
}
|
|
196
|
-
// Determine authenticated principal
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
// Determine authenticated principal. Harper v5 does not populate
|
|
203
|
+
// this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
|
|
204
|
+
// was always undefined and this ALWAYS fell back to the hardcoded "admin" —
|
|
205
|
+
// every approved consent grant was minted for the admin principal
|
|
206
|
+
// regardless of who actually approved it (ops-sal4 identity spoof).
|
|
207
|
+
// resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
|
|
208
|
+
// { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
|
|
209
|
+
// We do NOT silently fall back to "admin" on an unresolved principal — if
|
|
210
|
+
// no principal can be resolved, that's an error state, not an admin grant.
|
|
211
|
+
//
|
|
212
|
+
// SECURITY REVIEW (Sherlock): is resolving the approving principal via
|
|
213
|
+
// resolveAgentAuth the correct policy for "who may approve OAuth consent"
|
|
214
|
+
// in 1.0 — i.e. must the approver be Basic-authenticated as super_user/
|
|
215
|
+
// admin, or should any verified (non-admin) agent be able to approve its
|
|
216
|
+
// own consent grant? The previous code always resolved to "admin" for
|
|
217
|
+
// every caller, so this is a genuine policy decision, not just a bug fix.
|
|
218
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
219
|
+
if (auth.kind !== "agent") {
|
|
220
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
221
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const principalId = auth.agentId;
|
|
199
225
|
// Generate authorization code
|
|
200
226
|
const code = randomBytes(32).toString("base64url");
|
|
201
227
|
const now = nowISO();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Limit 50 events max.
|
|
11
11
|
*/
|
|
12
12
|
import { Resource, databases } from "@harperfast/harper";
|
|
13
|
-
import { allowVerified } from "./agent-auth.js";
|
|
13
|
+
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
14
14
|
export class OrgEventCatchup extends Resource {
|
|
15
15
|
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
16
16
|
// admin elevation). Any verified agent may catch up; participant scoping is in
|
|
@@ -21,9 +21,12 @@ export class OrgEventCatchup extends Resource {
|
|
|
21
21
|
}
|
|
22
22
|
// HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
|
|
23
23
|
async get(pathInfo) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Harper v5 does not populate this.request on Resource subclasses —
|
|
25
|
+
// getContext() is the only reliable path to the gate's tpsAgent/
|
|
26
|
+
// tpsAgentIsAdmin annotations (ops-sal4: the previous `(this as
|
|
27
|
+
// any).request` read was always undefined, so the ownership check below
|
|
28
|
+
// never ran — fail-open cross-agent read).
|
|
29
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
27
30
|
// Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
|
|
28
31
|
const participantId = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
|
|
29
32
|
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
@@ -32,8 +35,13 @@ export class OrgEventCatchup extends Resource {
|
|
|
32
35
|
if (!participantId) {
|
|
33
36
|
return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
34
37
|
}
|
|
35
|
-
// Auth:
|
|
36
|
-
|
|
38
|
+
// Auth: internal calls and admins pass unfiltered; a verified agent may only
|
|
39
|
+
// fetch its own catchup feed; anonymous is denied. allowRead() already
|
|
40
|
+
// blocks anonymous HTTP, but this handler must fail closed on its own too.
|
|
41
|
+
if (auth.kind === "anonymous") {
|
|
42
|
+
return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
43
|
+
}
|
|
44
|
+
if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId) {
|
|
37
45
|
return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
38
46
|
}
|
|
39
47
|
// Harper parses query params into pathInfo.conditions array:
|