@tpsdev-ai/flair 0.18.0 → 0.20.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/cli.js +1 -1
- package/dist/resources/Admin.js +12 -0
- package/dist/resources/AdminConnectors.js +6 -0
- package/dist/resources/AdminDashboard.js +9 -0
- package/dist/resources/AdminIdp.js +6 -0
- package/dist/resources/AdminInstance.js +6 -0
- package/dist/resources/AdminMemory.js +9 -1
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +187 -21
- package/dist/resources/MemoryBootstrap.js +99 -22
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Presence.js +9 -36
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/SemanticSearch.js +78 -33
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- package/dist/resources/agent-auth.js +10 -34
- package/dist/resources/auth-middleware.js +20 -53
- package/dist/resources/bm25-filter.js +9 -0
- package/dist/resources/dedup.js +24 -0
- package/dist/resources/ed25519-auth.js +119 -0
- package/dist/resources/memory-read-scope.js +112 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { allowVerified } from "./agent-auth.js";
|
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { wrapUntrusted } from "./content-safety.js";
|
|
5
5
|
import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
6
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
6
7
|
/**
|
|
7
8
|
* POST /MemoryBootstrap
|
|
8
9
|
*
|
|
@@ -12,11 +13,18 @@ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
|
12
13
|
* 2. Permanent memories (safety rules, core principles)
|
|
13
14
|
* 3. Recent memories (adaptive window)
|
|
14
15
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
16
|
+
* 4b. Teammate findings relevant to your task (flair#550 — the SAME scored
|
|
17
|
+
* task-relevant set as #4, split by origin: a grant-visible teammate's
|
|
18
|
+
* SHARED memory that scores against currentTask lands here instead of
|
|
19
|
+
* #4, attributed via "[via <agentId>]". Presentation only — what's
|
|
20
|
+
* readable is entirely Layer 1's resolveReadScope()/ops-2dm3; this only
|
|
21
|
+
* changes how an already-read cross-agent record is formatted/sectioned)
|
|
15
22
|
* 5. Relationship context (active relationships for mentioned entities)
|
|
16
23
|
* 6. Predicted context (based on channel/surface/subject hints)
|
|
17
24
|
* 7. Team roster (other active agents in this office + a search-first nudge —
|
|
18
|
-
* bootstrap
|
|
19
|
-
*
|
|
25
|
+
* bootstrap loads the caller's own memories plus any granted owner's
|
|
26
|
+
* SHARED memories (ops-2dm3 Layer 1, never their private ones), so this
|
|
27
|
+
* section nudges toward memory_search for anything beyond that window)
|
|
20
28
|
*
|
|
21
29
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
22
30
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -33,12 +41,23 @@ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
|
33
41
|
function estimateTokens(text) {
|
|
34
42
|
return Math.ceil(text.length / 4);
|
|
35
43
|
}
|
|
36
|
-
|
|
44
|
+
// `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
|
|
45
|
+
// whether to annotate attribution, never to change what's read (that
|
|
46
|
+
// boundary is resolveReadScope()'s job, upstream of this function). A
|
|
47
|
+
// cross-agent record always carries `_source` (set once, above, when the
|
|
48
|
+
// record's own agentId differs from the bootstrapping agent — see the
|
|
49
|
+
// allMemories loop), so `m._source !== agentId` is the "is this a
|
|
50
|
+
// teammate's finding" check; own memories never carry `_source` at all.
|
|
51
|
+
function formatMemory(m, agentId) {
|
|
37
52
|
const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
|
|
38
53
|
const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
|
|
39
54
|
const chain = m.supersedes ? " [supersedes earlier decision]" : "";
|
|
40
|
-
const
|
|
41
|
-
|
|
55
|
+
const attribution = m._source && m._source !== agentId ? `[via ${m._source}] ` : "";
|
|
56
|
+
const base = `${tag} ${attribution}${m.content}${date}${chain}`;
|
|
57
|
+
// Wrap flagged memories in safety delimiters — composes with attribution
|
|
58
|
+
// above (attribution is baked into `base` before wrapping, so a flagged
|
|
59
|
+
// teammate memory renders with BOTH the "[via <agent>]" tag and the
|
|
60
|
+
// untrusted-content wrapper).
|
|
42
61
|
if (m._safetyFlags && Array.isArray(m._safetyFlags) && m._safetyFlags.length > 0) {
|
|
43
62
|
return wrapUntrusted(base, m._source);
|
|
44
63
|
}
|
|
@@ -90,6 +109,7 @@ export class BootstrapMemories extends Resource {
|
|
|
90
109
|
predicted: [],
|
|
91
110
|
relationships: [],
|
|
92
111
|
relevant: [],
|
|
112
|
+
teammate: [],
|
|
93
113
|
events: [],
|
|
94
114
|
};
|
|
95
115
|
let tokenBudget = maxTokens;
|
|
@@ -178,12 +198,13 @@ export class BootstrapMemories extends Resource {
|
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
// --- 1c. Team roster + cross-agent search nudge ---
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// format per agent) so it's cheap enough to always
|
|
201
|
+
// Soul is still caller-own-only (unaffected here). Memory loading below
|
|
202
|
+
// (step 2) now also includes granted owners' SHARED memories (ops-2dm3
|
|
203
|
+
// Layer 1) — but this section stays: memory_search/SemanticSearch remains
|
|
204
|
+
// the deliberate, query-driven way to find a teammate's finding, vs.
|
|
205
|
+
// bootstrap's fixed recent/permanent window. This section is fixed-cost
|
|
206
|
+
// (no query text to format per agent) so it's cheap enough to always
|
|
207
|
+
// include, not budgeted.
|
|
187
208
|
//
|
|
188
209
|
// Permissive kind/status checks are DELIBERATE: Agent.ts registration
|
|
189
210
|
// defaults both (`kind ||= "agent"`, `status ||= "active"`), so pre-1.0
|
|
@@ -204,13 +225,39 @@ export class BootstrapMemories extends Resource {
|
|
|
204
225
|
// Agent table may not exist in older / standalone deployments
|
|
205
226
|
}
|
|
206
227
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
228
|
+
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
229
|
+
// (ops-2dm3 Layer 1 — never a granted owner's private ones). Centralized
|
|
230
|
+
// in resolveReadScope(): the condition is pushed into the Harper query
|
|
231
|
+
// (so the table itself never returns an out-of-scope row), and
|
|
232
|
+
// `scope.isAllowed` re-checks in-process as defense-in-depth (same
|
|
233
|
+
// belt-and-suspenders discipline as SemanticSearch's BM25 pre-fusion
|
|
234
|
+
// filter) — this is the #550 foundation: bootstrap can now safely expand
|
|
235
|
+
// beyond own-only without a parallel scoping rule.
|
|
236
|
+
const scope = await resolveReadScope(agentId);
|
|
207
237
|
const allMemories = [];
|
|
208
|
-
for await (const record of databases.flair.Memory.search()) {
|
|
209
|
-
if (record
|
|
238
|
+
for await (const record of databases.flair.Memory.search({ conditions: [scope.condition] })) {
|
|
239
|
+
if (!scope.isAllowed(record))
|
|
210
240
|
continue;
|
|
211
241
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
212
242
|
continue;
|
|
213
|
-
|
|
243
|
+
// ops-hesq: a past validTo ALWAYS means the record has been closed out
|
|
244
|
+
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
245
|
+
// validTo without necessarily setting `archived`), same root cause and
|
|
246
|
+
// fix as ops-9rc6's SemanticSearch/bm25-filter exclusion. Unconditional
|
|
247
|
+
// so a server-superseded record can't resurface in bootstrap just
|
|
248
|
+
// because its successor isn't co-present in this result set (the
|
|
249
|
+
// supersededIds filter further down only catches co-presence). A
|
|
250
|
+
// record with no validTo, or a future validTo, is unaffected.
|
|
251
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
252
|
+
continue;
|
|
253
|
+
// Attribution for cross-agent (granted-owner) records — same convention
|
|
254
|
+
// SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
255
|
+
// when the record also carries _safetyFlags (labels the untrusted-data
|
|
256
|
+
// wrapper with whose memory it is), it never forces wrapping on its own.
|
|
257
|
+
// Real Harper's search() results are non-extensible objects — mutating
|
|
258
|
+
// `record._source = ...` directly throws ("object is not extensible");
|
|
259
|
+
// shallow-copy instead of mutating in place.
|
|
260
|
+
allMemories.push(record.agentId !== agentId ? { ...record, _source: record.agentId } : record);
|
|
214
261
|
}
|
|
215
262
|
memoriesAvailable = allMemories.length;
|
|
216
263
|
// Build superseded set: exclude memories that have been replaced by newer ones
|
|
@@ -220,9 +267,18 @@ export class BootstrapMemories extends Resource {
|
|
|
220
267
|
supersededIds.add(m.supersedes);
|
|
221
268
|
}
|
|
222
269
|
const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
|
|
223
|
-
|
|
270
|
+
// #550 design boundary: the permanent / recent / predicted sections are the
|
|
271
|
+
// agent's OWN working context — own-only, always. Post-Layer-1
|
|
272
|
+
// `activeMemories` also carries grant-visible teammate records (`_source`
|
|
273
|
+
// set), but grant-visibility exists to feed the task-relevant "Teammate
|
|
274
|
+
// findings" surfacing (#550) below, NOT to blend a teammate's memories into
|
|
275
|
+
// the reader's recent/permanent/predicted view. So these three sections
|
|
276
|
+
// filter to own (`!m._source`); team knowledge surfaces only when
|
|
277
|
+
// task-relevant (the teammate section) or via an explicit memory_search.
|
|
278
|
+
const ownMemories = activeMemories.filter((m) => !m._source);
|
|
279
|
+
const permanent = ownMemories.filter((m) => m.durability === "permanent");
|
|
224
280
|
for (const m of permanent) {
|
|
225
|
-
const line = formatMemory(m);
|
|
281
|
+
const line = formatMemory(m, agentId);
|
|
226
282
|
const cost = estimateTokens(line);
|
|
227
283
|
if (cost <= tokenBudget) {
|
|
228
284
|
sections.permanent.push(line);
|
|
@@ -236,7 +292,7 @@ export class BootstrapMemories extends Resource {
|
|
|
236
292
|
// --- 3. Recent memories (adaptive window) ---
|
|
237
293
|
// Start with 48h. If nothing found, widen to 7d, then 30d.
|
|
238
294
|
// This prevents empty recent sections for agents that were idle.
|
|
239
|
-
const nonPermanent =
|
|
295
|
+
const nonPermanent = ownMemories
|
|
240
296
|
.filter((m) => m.durability !== "permanent" && m.createdAt)
|
|
241
297
|
.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
242
298
|
let effectiveSince;
|
|
@@ -258,7 +314,7 @@ export class BootstrapMemories extends Resource {
|
|
|
258
314
|
const recentBudget = Math.floor(tokenBudget * 0.4);
|
|
259
315
|
let recentSpent = 0;
|
|
260
316
|
for (const m of recent) {
|
|
261
|
-
const line = formatMemory(m);
|
|
317
|
+
const line = formatMemory(m, agentId);
|
|
262
318
|
const cost = estimateTokens(line);
|
|
263
319
|
if (recentSpent + cost > recentBudget) {
|
|
264
320
|
memoriesTruncated++;
|
|
@@ -282,7 +338,7 @@ export class BootstrapMemories extends Resource {
|
|
|
282
338
|
...permanent.map((m) => m.id),
|
|
283
339
|
...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
|
|
284
340
|
]);
|
|
285
|
-
const subjectMemories =
|
|
341
|
+
const subjectMemories = ownMemories
|
|
286
342
|
.filter((m) => !includedIds.has(m.id) &&
|
|
287
343
|
m.subject &&
|
|
288
344
|
predictedSubjects.includes(m.subject.toLowerCase()) &&
|
|
@@ -292,7 +348,7 @@ export class BootstrapMemories extends Resource {
|
|
|
292
348
|
const predictedBudget = Math.floor(tokenBudget * 0.3);
|
|
293
349
|
let predictedSpent = 0;
|
|
294
350
|
for (const m of subjectMemories) {
|
|
295
|
-
const line = formatMemory(m);
|
|
351
|
+
const line = formatMemory(m, agentId);
|
|
296
352
|
const cost = estimateTokens(line);
|
|
297
353
|
if (predictedSpent + cost > predictedBudget) {
|
|
298
354
|
memoriesTruncated++;
|
|
@@ -362,12 +418,25 @@ export class BootstrapMemories extends Resource {
|
|
|
362
418
|
})
|
|
363
419
|
.filter((s) => s.score > 0.3)
|
|
364
420
|
.sort((a, b) => b.score - a.score);
|
|
421
|
+
// #550: split the scored, task-relevant set by origin. Own findings
|
|
422
|
+
// go to `relevant` as before; a teammate's (grant-visible, already
|
|
423
|
+
// read-scoped by Layer 1 — `m._source` is only ever set for a
|
|
424
|
+
// cross-agent record, see the allMemories loop above) go to the new
|
|
425
|
+
// `teammate` section so the agent can tell it apart at a glance.
|
|
426
|
+
// Both draw from the SAME `tokenBudget` in one score-ordered pass —
|
|
427
|
+
// highest-relevance memories win the remaining budget regardless of
|
|
428
|
+
// which section they land in, so neither section double-spends.
|
|
365
429
|
for (const { memory: m } of scored) {
|
|
366
|
-
const line = formatMemory(m);
|
|
430
|
+
const line = formatMemory(m, agentId);
|
|
367
431
|
const cost = estimateTokens(line);
|
|
368
432
|
if (cost > tokenBudget)
|
|
369
433
|
continue;
|
|
370
|
-
|
|
434
|
+
if (m._source) {
|
|
435
|
+
sections.teammate.push(line);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
sections.relevant.push(line);
|
|
439
|
+
}
|
|
371
440
|
tokenBudget -= cost;
|
|
372
441
|
memoriesIncluded++;
|
|
373
442
|
}
|
|
@@ -428,6 +497,13 @@ export class BootstrapMemories extends Resource {
|
|
|
428
497
|
if (sections.relevant.length > 0) {
|
|
429
498
|
parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
|
|
430
499
|
}
|
|
500
|
+
// #550: teammate findings relevant to the current task — right after the
|
|
501
|
+
// agent's own task-relevant knowledge. Empty section renders nothing
|
|
502
|
+
// (no header) so a bootstrap with no grant-visible teammate findings for
|
|
503
|
+
// this task looks exactly as it did before this feature.
|
|
504
|
+
if (sections.teammate.length > 0) {
|
|
505
|
+
parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
|
|
506
|
+
}
|
|
431
507
|
if (sections.events.length > 0) {
|
|
432
508
|
parts.push("## Recent Org Events\n" + sections.events.join("\n"));
|
|
433
509
|
}
|
|
@@ -445,6 +521,7 @@ export class BootstrapMemories extends Resource {
|
|
|
445
521
|
predicted: sections.predicted.length,
|
|
446
522
|
relationships: sections.relationships.length,
|
|
447
523
|
relevant: sections.relevant.length,
|
|
524
|
+
teammate: sections.teammate.length,
|
|
448
525
|
events: sections.events.length,
|
|
449
526
|
},
|
|
450
527
|
tokenEstimate: soulTokens + memoryTokens,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* MemoryGrant — an agent (ownerId) grants another (granteeId) scoped access to its
|
|
7
8
|
* memories. Self-authorizes now that the global gate is non-rejecting (previously a
|
|
@@ -16,6 +17,52 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
16
17
|
_auth() {
|
|
17
18
|
return resolveAgentAuth(this.getContext?.());
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
22
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
23
|
+
* WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
|
|
24
|
+
* leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /MemoryGrant`) outside search(), so neither was gated
|
|
26
|
+
* before this fix — an anonymous caller got a 200 with full grant content.
|
|
27
|
+
* Per-record owner/grantee scoping happens in get() below; the collection
|
|
28
|
+
* scope is still in search().
|
|
29
|
+
*/
|
|
30
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
31
|
+
/**
|
|
32
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
33
|
+
* collection reads (memory-soul-read-gate family fix). A grant is visible
|
|
34
|
+
* to either party (ownerId OR granteeId), mirroring search()'s owner-OR-
|
|
35
|
+
* grantee scope. Never distinguishes "doesn't exist" from "exists but not
|
|
36
|
+
* yours" — both return 404, never 403, so a denied caller can't use get()
|
|
37
|
+
* to enumerate other agents' grant ids.
|
|
38
|
+
*/
|
|
39
|
+
async get(target) {
|
|
40
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
41
|
+
// `isCollection === true`, and are governed by search() (same owner/
|
|
42
|
+
// grantee scoping). Only a genuine by-id get is ownership-checked below
|
|
43
|
+
// — see Memory.ts's get() for the full rationale (same bug class).
|
|
44
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
45
|
+
return this.search(target);
|
|
46
|
+
}
|
|
47
|
+
const auth = await this._auth();
|
|
48
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
49
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
50
|
+
if (auth.kind === "anonymous") {
|
|
51
|
+
return NOT_FOUND();
|
|
52
|
+
}
|
|
53
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
54
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
55
|
+
return super.get(target);
|
|
56
|
+
}
|
|
57
|
+
// Non-admin agent: visible if it's the owner OR the grantee (parity with
|
|
58
|
+
// search()'s owner-OR-grantee scope).
|
|
59
|
+
const record = await super.get(target);
|
|
60
|
+
if (!record)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
if (record.ownerId !== auth.agentId && record.granteeId !== auth.agentId)
|
|
63
|
+
return NOT_FOUND();
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
19
66
|
async search(query) {
|
|
20
67
|
const auth = await this._auth();
|
|
21
68
|
if (auth.kind === "anonymous")
|
|
@@ -59,7 +106,13 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
59
106
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
60
107
|
return super.delete(id, context);
|
|
61
108
|
}
|
|
62
|
-
|
|
109
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
110
|
+
// (a truthy Response) for a non-owner/non-grantee id, which would
|
|
111
|
+
// otherwise defeat the `if (!record)` check below and mis-route a
|
|
112
|
+
// genuinely-missing record into the FORBIDDEN branch instead of a clean
|
|
113
|
+
// super.delete(id, context) no-op. Mirrors Memory.ts's delete() — same
|
|
114
|
+
// rationale, same fix.
|
|
115
|
+
const record = await super.get(id);
|
|
63
116
|
if (!record)
|
|
64
117
|
return super.delete(id, context);
|
|
65
118
|
if (record.ownerId !== auth.agentId) {
|
|
@@ -18,9 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { databases } from "@harperfast/harper";
|
|
20
20
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
21
|
-
import { b64ToArrayBuffer } from "./
|
|
21
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
22
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
|
-
const WINDOW_MS = 30_000;
|
|
24
23
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
25
24
|
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
|
|
26
25
|
function idleThresholdMs() {
|
|
@@ -31,36 +30,12 @@ function offlineThresholdMs() {
|
|
|
31
30
|
const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
|
|
32
31
|
return env ? Number(env) || 600_000 : 600_000;
|
|
33
32
|
}
|
|
34
|
-
// ─── Nonce replay
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
nonceSeen.delete(k);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
44
|
-
// b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + agent-auth.ts
|
|
45
|
-
// so the base64/base64url decoder can't drift across the three auth call sites).
|
|
46
|
-
const keyCache = new Map();
|
|
47
|
-
async function importEd25519Key(publicKeyStr) {
|
|
48
|
-
if (keyCache.has(publicKeyStr))
|
|
49
|
-
return keyCache.get(publicKeyStr);
|
|
50
|
-
let raw;
|
|
51
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
52
|
-
const bytes = new Uint8Array(32);
|
|
53
|
-
for (let i = 0; i < 32; i++)
|
|
54
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
55
|
-
raw = bytes.buffer;
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
59
|
-
}
|
|
60
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
61
|
-
keyCache.set(publicKeyStr, key);
|
|
62
|
-
return key;
|
|
63
|
-
}
|
|
33
|
+
// ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
|
|
34
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
35
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
36
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
37
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
38
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
64
39
|
// ─── Status derivation (pure — exported for unit testing) ─────────────────────
|
|
65
40
|
export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
|
|
66
41
|
const idle = idleMs ?? idleThresholdMs();
|
|
@@ -195,9 +170,7 @@ export class Presence extends databases.flair.Presence {
|
|
|
195
170
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
|
|
196
171
|
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
197
172
|
}
|
|
198
|
-
|
|
199
|
-
const nonceKey = `${headerAgentId}:${nonce}`;
|
|
200
|
-
if (nonceSeen.has(nonceKey)) {
|
|
173
|
+
if (isNonceReplay(headerAgentId, nonce, now)) {
|
|
201
174
|
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
202
175
|
}
|
|
203
176
|
const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
|
|
@@ -219,7 +192,7 @@ export class Presence extends databases.flair.Presence {
|
|
|
219
192
|
catch (e) {
|
|
220
193
|
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
221
194
|
}
|
|
222
|
-
|
|
195
|
+
recordNonce(headerAgentId, nonce, ts);
|
|
223
196
|
agentId = headerAgentId;
|
|
224
197
|
}
|
|
225
198
|
// ── Validate body ────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
/**
|
|
5
6
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
6
7
|
*
|
|
@@ -13,6 +14,53 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
* Admin agents can query across all agents.
|
|
14
15
|
*/
|
|
15
16
|
export class Relationship extends databases.flair.Relationship {
|
|
17
|
+
/**
|
|
18
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
19
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
20
|
+
* WorkspaceState.ts). Closes the same P0 leak: Harper routes
|
|
21
|
+
* `GET /Relationship/<id>` to get() and the collection describe
|
|
22
|
+
* (`GET /Relationship`) outside search(), so neither was gated before this
|
|
23
|
+
* fix — an anonymous caller got a 200 with full record content. Per-record
|
|
24
|
+
* ownership scoping happens in get() below; the collection scope is still
|
|
25
|
+
* in search().
|
|
26
|
+
*/
|
|
27
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
28
|
+
/**
|
|
29
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
30
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
31
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
32
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
33
|
+
* relationship ids.
|
|
34
|
+
*/
|
|
35
|
+
async get(target) {
|
|
36
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
37
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
38
|
+
// scoping). Only a genuine by-id get is ownership-checked below — see
|
|
39
|
+
// Memory.ts's get() for the full rationale (same bug class: without this
|
|
40
|
+
// guard, a query's RequestTarget would flow into super.get(), return the
|
|
41
|
+
// whole result set, and the single-record ownership check below would
|
|
42
|
+
// find no `.agentId` on it).
|
|
43
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
44
|
+
return this.search(target);
|
|
45
|
+
}
|
|
46
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
47
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
48
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
49
|
+
if (auth.kind === "anonymous") {
|
|
50
|
+
return NOT_FOUND();
|
|
51
|
+
}
|
|
52
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
53
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
54
|
+
return super.get(target);
|
|
55
|
+
}
|
|
56
|
+
// Non-admin agent: only its own relationships.
|
|
57
|
+
const record = await super.get(target);
|
|
58
|
+
if (!record)
|
|
59
|
+
return NOT_FOUND();
|
|
60
|
+
if (record.agentId !== auth.agentId)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
16
64
|
async search(query) {
|
|
17
65
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
66
|
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
|
@@ -4,6 +4,8 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
|
4
4
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
5
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
6
|
import { wrapUntrusted } from "./content-safety.js";
|
|
7
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
8
|
+
import { cosineSimilarity } from "./dedup.js";
|
|
7
9
|
import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
|
|
8
10
|
// Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
|
|
9
11
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
@@ -28,8 +30,9 @@ export class SemanticSearch extends Resource {
|
|
|
28
30
|
// Self-authorize via the Ed25519 agent verify instead of relying on the auth
|
|
29
31
|
// gate's admin super_user elevation (removed in the auth reshape). Any
|
|
30
32
|
// cryptographically-verified agent may search; per-agent RESULT scoping is
|
|
31
|
-
// enforced in post() below (an agent only sees its own
|
|
32
|
-
//
|
|
33
|
+
// enforced in post() below (an agent only sees its own memories, any
|
|
34
|
+
// visibility, plus granted owners' SHARED memories — never their private
|
|
35
|
+
// ones). Without this, Harper's default denies the POST for the
|
|
33
36
|
// least-privilege flair_agent role (AccessViolation 403).
|
|
34
37
|
async allowCreate() {
|
|
35
38
|
return allowVerified(this.getContext?.());
|
|
@@ -77,21 +80,13 @@ export class SemanticSearch extends Resource {
|
|
|
77
80
|
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
78
81
|
? authenticatedAgent
|
|
79
82
|
: bodyAgentId;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})) {
|
|
88
|
-
if (grant.scope === "search" || grant.scope === "read") {
|
|
89
|
-
searchAgentIds.add(grant.ownerId);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch { /* MemoryGrant may not exist */ }
|
|
94
|
-
}
|
|
83
|
+
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
84
|
+
// (ops-2dm3 Layer 1). Centralized in resolveReadScope() — this used to be
|
|
85
|
+
// an inline grant-resolution loop here PLUS a `visibility === "office"`
|
|
86
|
+
// global OR-clause below that leaked ANY authenticated agent's read of
|
|
87
|
+
// ANY other agent's office-visible memories (ops-nzxa). Both are gone;
|
|
88
|
+
// this is the ONE scoping resolution for this endpoint now.
|
|
89
|
+
const scope = agentId ? await resolveReadScope(agentId) : null;
|
|
95
90
|
// Generate query embedding
|
|
96
91
|
let qEmb = queryEmbedding;
|
|
97
92
|
if (!qEmb && q) {
|
|
@@ -135,21 +130,12 @@ export class SemanticSearch extends Resource {
|
|
|
135
130
|
}
|
|
136
131
|
// ─── Build conditions for Harper query ──────────────────────────────────
|
|
137
132
|
const conditions = [];
|
|
138
|
-
// Agent scoping:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{ attribute: "agentId", comparator: "equals", value: id },
|
|
145
|
-
{ attribute: "visibility", comparator: "equals", value: "office" },
|
|
146
|
-
],
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
else if (searchAgentIds.size > 1) {
|
|
150
|
-
const agentConditions = [...searchAgentIds].map(id => ({ attribute: "agentId", comparator: "equals", value: id }));
|
|
151
|
-
agentConditions.push({ attribute: "visibility", comparator: "equals", value: "office" });
|
|
152
|
-
conditions.push({ operator: "or", conditions: agentConditions });
|
|
133
|
+
// Agent scoping: own (any visibility) OR granted-owner's SHARED memories
|
|
134
|
+
// (private-exclusion) — the centralized read-scope condition. No agentId
|
|
135
|
+
// → no scoping condition pushed (trusted internal call / admin without a
|
|
136
|
+
// target agentId — matches the pre-existing unscoped fallback).
|
|
137
|
+
if (scope) {
|
|
138
|
+
conditions.push(scope.condition);
|
|
153
139
|
}
|
|
154
140
|
// Exclude archived records. Use "not_equal" (Harper v5 comparator) instead of
|
|
155
141
|
// "equals false" so records without the archived field are included.
|
|
@@ -220,6 +206,10 @@ export class SemanticSearch extends Resource {
|
|
|
220
206
|
continue;
|
|
221
207
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
222
208
|
continue;
|
|
209
|
+
// ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
|
|
210
|
+
// loop below for the full rationale) — applies regardless of asOf.
|
|
211
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
212
|
+
continue;
|
|
223
213
|
semRecords.push(record);
|
|
224
214
|
semIds.push(record.id);
|
|
225
215
|
}
|
|
@@ -325,7 +315,58 @@ export class SemanticSearch extends Resource {
|
|
|
325
315
|
continue;
|
|
326
316
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
327
317
|
continue;
|
|
328
|
-
|
|
318
|
+
// ops-9rc6: a past validTo ALWAYS means the record has been closed out
|
|
319
|
+
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
320
|
+
// validTo without necessarily setting `archived`). Unconditional, not
|
|
321
|
+
// gated on `asOf`, so a server-superseded record can't resurface in
|
|
322
|
+
// the DEFAULT recall path just because its successor isn't
|
|
323
|
+
// co-present in this result set (the supersededIds filter further
|
|
324
|
+
// down only catches co-presence). A record with no validTo, or a
|
|
325
|
+
// future validTo, is unaffected.
|
|
326
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
327
|
+
continue;
|
|
328
|
+
let semanticScore;
|
|
329
|
+
if (record.$distance !== undefined) {
|
|
330
|
+
semanticScore = distanceToSimilarity(record.$distance);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
// ─── ops-syzm: Harper's cosine-sort query omits $distance for a
|
|
334
|
+
// SINGLETON post-filter result set — the SAME quirk root-caused and
|
|
335
|
+
// fixed for the dedup path in ops-ume4 (resources/Memory.ts
|
|
336
|
+
// findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
|
|
337
|
+
// Sort ORDER is still correct; only the numeric `$distance`
|
|
338
|
+
// annotation is missing on that one record, regardless of the
|
|
339
|
+
// query's own `limit` (reproduced here with candidateLimit=50, not
|
|
340
|
+
// just limit=1 — the trigger is the post-filter MATCH COUNT, not the
|
|
341
|
+
// requested limit).
|
|
342
|
+
//
|
|
343
|
+
// ops-2dm3 Layer 1 made this common: the no-grants agent scope used
|
|
344
|
+
// to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
|
|
345
|
+
// {visibility=="office"}]}` condition; resolveReadScope() now emits
|
|
346
|
+
// a PLAIN single `{agentId==X}` condition for the common (no-grants)
|
|
347
|
+
// case, so a scoped search against an agent with exactly one
|
|
348
|
+
// matching memory hits this Harper quirk directly. Before, the OR
|
|
349
|
+
// wrap incidentally avoided the singleton shape. The old `?? 1`
|
|
350
|
+
// fallback silently collapsed this to similarity 0 — read by
|
|
351
|
+
// callers (including the clean-VM CI gate's single-memory init
|
|
352
|
+
// probe, #533) as "embeddings not loaded", which is WRONG:
|
|
353
|
+
// embeddings ARE loaded, only the score was computed incorrectly.
|
|
354
|
+
//
|
|
355
|
+
// Fix: point-lookup the record by id (a plain get(), unaffected by
|
|
356
|
+
// the sort-query quirk — selecting "embedding" directly on the SAME
|
|
357
|
+
// sort-by-embedding query comes back as a bare scalar per ops-ume4's
|
|
358
|
+
// findings) and compute cosine similarity ourselves in JS from its
|
|
359
|
+
// real stored `embedding` vector against this query's `qEmb`, via
|
|
360
|
+
// the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
|
|
361
|
+
// Only done on this (rare) undefined-$distance branch — never adds
|
|
362
|
+
// a point-lookup to the normal per-record path. If the stored
|
|
363
|
+
// embedding is missing/empty (e.g. a legacy pre-embedding record),
|
|
364
|
+
// cosineSimilarity returns 0 — the same safe "no match" the old
|
|
365
|
+
// `?? 1` fallback produced, never a false-high score.
|
|
366
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
|
|
367
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
368
|
+
semanticScore = cosineSimilarity(qEmb, storedEmbedding);
|
|
369
|
+
}
|
|
329
370
|
let keywordHit = false;
|
|
330
371
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
331
372
|
keywordHit = true;
|
|
@@ -364,6 +405,10 @@ export class SemanticSearch extends Resource {
|
|
|
364
405
|
continue;
|
|
365
406
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
366
407
|
continue;
|
|
408
|
+
// ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
|
|
409
|
+
// loop above for the full rationale) — applies regardless of asOf.
|
|
410
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
411
|
+
continue;
|
|
367
412
|
let keywordHit = false;
|
|
368
413
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
369
414
|
keywordHit = true;
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
5
|
/**
|
|
@@ -19,6 +19,17 @@ async function enforceWriteAuth(self, data) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
export class Soul extends databases.flair.Soul {
|
|
22
|
+
/**
|
|
23
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
24
|
+
* leak: Harper routes `GET /Soul/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /Soul`) to a path outside search()/allow* — neither was
|
|
26
|
+
* gated before this fix, so an anonymous caller got a 200 with full soul
|
|
27
|
+
* content. Deliberately NO get() override / per-agent scoping on top of
|
|
28
|
+
* this: souls are identity/discovery data, intentionally readable by any
|
|
29
|
+
* verified agent — same posture as Agent.ts's allowRead. Write ownership
|
|
30
|
+
* is unaffected — enforceWriteAuth() below already gates post()/put().
|
|
31
|
+
*/
|
|
32
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
22
33
|
async post(content, context) {
|
|
23
34
|
const denied = await enforceWriteAuth(this, content);
|
|
24
35
|
if (denied)
|