@tpsdev-ai/flair 0.4.15 → 0.5.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.
@@ -0,0 +1,381 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { handleJwtBearerGrant } from "./XAA.js";
4
+ /**
5
+ * OAuth 2.1 Authorization Server for Flair.
6
+ *
7
+ * Endpoints (all mapped via Harper's resource routing):
8
+ * GET /OAuthMetadata → /.well-known/oauth-authorization-server
9
+ * POST /OAuthRegister → /oauth/register (DCR)
10
+ * GET /OAuthAuthorize → /oauth/authorize (consent screen)
11
+ * POST /OAuthToken → /oauth/token (token exchange)
12
+ * POST /OAuthRevoke → /oauth/revoke
13
+ *
14
+ * 1.0 constraints (per FLAIR-PRINCIPALS § 2):
15
+ * - Only https://claude.com/api/mcp/auth_callback permitted as redirect URI
16
+ * - PKCE required (S256 only)
17
+ * - Access tokens: max 1 hour
18
+ * - Refresh token rotation on each use
19
+ * - No "remember this decision" — each authorize is explicit
20
+ */
21
+ const ALLOWED_REDIRECT_URI = "https://claude.com/api/mcp/auth_callback";
22
+ const ACCESS_TOKEN_TTL_MS = 3600_000; // 1 hour
23
+ const REFRESH_TOKEN_TTL_MS = 7 * 86400_000; // 7 days
24
+ const AUTH_CODE_TTL_MS = 600_000; // 10 minutes
25
+ function sha256(input) {
26
+ return createHash("sha256").update(input).digest("hex");
27
+ }
28
+ function randomToken(prefix) {
29
+ return `${prefix}${randomBytes(32).toString("base64url")}`;
30
+ }
31
+ function nowISO() {
32
+ return new Date().toISOString();
33
+ }
34
+ function futureISO(ms) {
35
+ return new Date(Date.now() + ms).toISOString();
36
+ }
37
+ // ─── Discovery metadata ──────────────────────────────────────────────────────
38
+ export class OAuthMetadata extends Resource {
39
+ async get() {
40
+ const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
41
+ return {
42
+ issuer: baseUrl,
43
+ authorization_endpoint: `${baseUrl}/OAuthAuthorize`,
44
+ token_endpoint: `${baseUrl}/OAuthToken`,
45
+ registration_endpoint: `${baseUrl}/OAuthRegister`,
46
+ revocation_endpoint: `${baseUrl}/OAuthRevoke`,
47
+ response_types_supported: ["code"],
48
+ grant_types_supported: [
49
+ "authorization_code",
50
+ "refresh_token",
51
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
52
+ ],
53
+ token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
54
+ code_challenge_methods_supported: ["S256"],
55
+ scopes_supported: [
56
+ "memory:read", "memory:write", "memory:admin",
57
+ "principal:read", "principal:admin",
58
+ "connector:read", "connector:admin",
59
+ ],
60
+ extensions_supported: [
61
+ "io.modelcontextprotocol/enterprise-managed-authorization",
62
+ ],
63
+ };
64
+ }
65
+ }
66
+ // ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
67
+ export class OAuthRegister extends Resource {
68
+ async post(data) {
69
+ const redirectUris = data?.redirect_uris ?? [];
70
+ const clientName = data?.client_name ?? "Unknown Client";
71
+ // 1.0: only claude.com redirect URI permitted
72
+ for (const uri of redirectUris) {
73
+ if (uri !== ALLOWED_REDIRECT_URI) {
74
+ return new Response(JSON.stringify({
75
+ error: "invalid_redirect_uri",
76
+ error_description: `Only ${ALLOWED_REDIRECT_URI} is permitted in 1.0`,
77
+ }), { status: 400, headers: { "content-type": "application/json" } });
78
+ }
79
+ }
80
+ if (redirectUris.length === 0) {
81
+ redirectUris.push(ALLOWED_REDIRECT_URI);
82
+ }
83
+ const clientId = `flair_cl_${randomBytes(16).toString("base64url")}`;
84
+ const now = nowISO();
85
+ await databases.flair.OAuthClient.put({
86
+ id: clientId,
87
+ name: clientName,
88
+ redirectUris,
89
+ grantTypes: ["authorization_code", "refresh_token"],
90
+ scope: data?.scope ?? "memory:read memory:write",
91
+ registeredBy: "dcr",
92
+ createdAt: now,
93
+ updatedAt: now,
94
+ });
95
+ return {
96
+ client_id: clientId,
97
+ client_name: clientName,
98
+ redirect_uris: redirectUris,
99
+ grant_types: ["authorization_code", "refresh_token"],
100
+ token_endpoint_auth_method: "none",
101
+ };
102
+ }
103
+ }
104
+ // ─── Authorization endpoint ──────────────────────────────────────────────────
105
+ export class OAuthAuthorize extends Resource {
106
+ async get() {
107
+ // In 1.0, this returns a simple HTML consent page.
108
+ // The user (Nathan) approves or denies, which POSTs back.
109
+ const request = this.request;
110
+ const url = new URL(request?.url ?? "http://localhost", "http://localhost");
111
+ const clientId = url.searchParams.get("client_id") ?? "";
112
+ const redirectUri = url.searchParams.get("redirect_uri") ?? "";
113
+ const responseType = url.searchParams.get("response_type") ?? "";
114
+ const scope = url.searchParams.get("scope") ?? "memory:read";
115
+ const state = url.searchParams.get("state") ?? "";
116
+ const codeChallenge = url.searchParams.get("code_challenge") ?? "";
117
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "";
118
+ if (responseType !== "code") {
119
+ return new Response(JSON.stringify({ error: "unsupported_response_type" }), {
120
+ status: 400, headers: { "content-type": "application/json" },
121
+ });
122
+ }
123
+ if (redirectUri && redirectUri !== ALLOWED_REDIRECT_URI) {
124
+ return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), {
125
+ status: 400, headers: { "content-type": "application/json" },
126
+ });
127
+ }
128
+ // Verify client exists
129
+ const client = await databases.flair.OAuthClient.get(clientId);
130
+ if (!client) {
131
+ return new Response(JSON.stringify({ error: "invalid_client" }), {
132
+ status: 400, headers: { "content-type": "application/json" },
133
+ });
134
+ }
135
+ // Server-rendered consent page (minimal HTML, no JS frameworks)
136
+ const html = `<!DOCTYPE html>
137
+ <html><head><title>Flair — Authorize</title>
138
+ <style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 20px}
139
+ h1{font-size:1.4em}button{padding:10px 24px;font-size:1em;border:none;border-radius:6px;cursor:pointer;margin-right:8px}
140
+ .approve{background:#2563eb;color:#fff}.deny{background:#e5e7eb;color:#333}
141
+ .scope{background:#f3f4f6;padding:8px 12px;border-radius:4px;margin:4px 0;font-family:monospace}</style></head>
142
+ <body>
143
+ <h1>Authorize ${client.name || clientId}</h1>
144
+ <p>This application wants to access your Flair memories:</p>
145
+ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
146
+ <form method="POST" action="/OAuthAuthorize" style="margin-top:24px">
147
+ <input type="hidden" name="client_id" value="${clientId}">
148
+ <input type="hidden" name="redirect_uri" value="${redirectUri || ALLOWED_REDIRECT_URI}">
149
+ <input type="hidden" name="scope" value="${scope}">
150
+ <input type="hidden" name="state" value="${state}">
151
+ <input type="hidden" name="code_challenge" value="${codeChallenge}">
152
+ <input type="hidden" name="code_challenge_method" value="${codeChallengeMethod}">
153
+ <button type="submit" name="action" value="approve" class="approve">Approve</button>
154
+ <button type="submit" name="action" value="deny" class="deny">Deny</button>
155
+ </form></body></html>`;
156
+ return new Response(html, {
157
+ status: 200,
158
+ headers: { "content-type": "text/html; charset=utf-8" },
159
+ });
160
+ }
161
+ async post(data) {
162
+ const action = data?.action;
163
+ const clientId = data?.client_id ?? "";
164
+ const redirectUri = data?.redirect_uri || ALLOWED_REDIRECT_URI;
165
+ const scope = data?.scope ?? "memory:read";
166
+ const state = data?.state ?? "";
167
+ const codeChallenge = data?.code_challenge ?? "";
168
+ const codeChallengeMethod = data?.code_challenge_method ?? "S256";
169
+ // Sherlock 2026-04-11: validate redirect_uri in POST handler too.
170
+ // Without this, an attacker can CSRF the approval form with an arbitrary
171
+ // redirect_uri, stealing the authorization code via open redirect.
172
+ if (redirectUri !== ALLOWED_REDIRECT_URI) {
173
+ return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), {
174
+ status: 400, headers: { "content-type": "application/json" },
175
+ });
176
+ }
177
+ if (action === "deny") {
178
+ const params = new URLSearchParams({ error: "access_denied", state });
179
+ return Response.redirect(`${redirectUri}?${params}`, 302);
180
+ }
181
+ // Determine authenticated principal
182
+ const request = this.request;
183
+ const principalId = request?.tpsAgent ?? "admin";
184
+ // Generate authorization code
185
+ const code = randomBytes(32).toString("base64url");
186
+ const now = nowISO();
187
+ await databases.flair.OAuthAuthCode.put({
188
+ id: code,
189
+ clientId,
190
+ principalId,
191
+ redirectUri,
192
+ scope,
193
+ codeChallenge,
194
+ codeChallengeMethod,
195
+ expiresAt: futureISO(AUTH_CODE_TTL_MS),
196
+ used: false,
197
+ createdAt: now,
198
+ });
199
+ const params = new URLSearchParams({ code, state });
200
+ return Response.redirect(`${redirectUri}?${params}`, 302);
201
+ }
202
+ }
203
+ // ─── Token endpoint ──────────────────────────────────────────────────────────
204
+ export class OAuthToken extends Resource {
205
+ async post(data) {
206
+ const grantType = data?.grant_type;
207
+ if (grantType === "authorization_code") {
208
+ return this.handleAuthorizationCode(data);
209
+ }
210
+ else if (grantType === "refresh_token") {
211
+ return this.handleRefreshToken(data);
212
+ }
213
+ else if (grantType === "urn:ietf:params:oauth:grant-type:jwt-bearer") {
214
+ return handleJwtBearerGrant(data);
215
+ }
216
+ return new Response(JSON.stringify({ error: "unsupported_grant_type" }), {
217
+ status: 400, headers: { "content-type": "application/json" },
218
+ });
219
+ }
220
+ async handleAuthorizationCode(data) {
221
+ const code = data?.code;
222
+ const clientId = data?.client_id;
223
+ const redirectUri = data?.redirect_uri;
224
+ const codeVerifier = data?.code_verifier;
225
+ if (!code || !clientId) {
226
+ return new Response(JSON.stringify({ error: "invalid_request" }), {
227
+ status: 400, headers: { "content-type": "application/json" },
228
+ });
229
+ }
230
+ // Look up auth code
231
+ const authCode = await databases.flair.OAuthAuthCode.get(code);
232
+ if (!authCode) {
233
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
234
+ status: 400, headers: { "content-type": "application/json" },
235
+ });
236
+ }
237
+ // Validate
238
+ if (authCode.used) {
239
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code already used" }), {
240
+ status: 400, headers: { "content-type": "application/json" },
241
+ });
242
+ }
243
+ if (authCode.clientId !== clientId) {
244
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
245
+ status: 400, headers: { "content-type": "application/json" },
246
+ });
247
+ }
248
+ if (new Date(authCode.expiresAt) < new Date()) {
249
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code expired" }), {
250
+ status: 400, headers: { "content-type": "application/json" },
251
+ });
252
+ }
253
+ if (redirectUri && authCode.redirectUri !== redirectUri) {
254
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
255
+ status: 400, headers: { "content-type": "application/json" },
256
+ });
257
+ }
258
+ // PKCE verification
259
+ if (authCode.codeChallenge) {
260
+ if (!codeVerifier) {
261
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required" }), {
262
+ status: 400, headers: { "content-type": "application/json" },
263
+ });
264
+ }
265
+ const expectedChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
266
+ if (expectedChallenge !== authCode.codeChallenge) {
267
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }), {
268
+ status: 400, headers: { "content-type": "application/json" },
269
+ });
270
+ }
271
+ }
272
+ // Mark code as used
273
+ await databases.flair.OAuthAuthCode.put({ ...authCode, used: true });
274
+ // Issue tokens
275
+ return this.issueTokenPair(authCode.clientId, authCode.principalId, authCode.scope);
276
+ }
277
+ async handleRefreshToken(data) {
278
+ const refreshTokenRaw = data?.refresh_token;
279
+ const clientId = data?.client_id;
280
+ if (!refreshTokenRaw) {
281
+ return new Response(JSON.stringify({ error: "invalid_request" }), {
282
+ status: 400, headers: { "content-type": "application/json" },
283
+ });
284
+ }
285
+ const tokenHash = sha256(refreshTokenRaw);
286
+ // Find refresh token by hash
287
+ let refreshRecord = null;
288
+ for await (const t of databases.flair.OAuthToken.search({
289
+ conditions: [
290
+ { attribute: "tokenHash", comparator: "equals", value: tokenHash },
291
+ { attribute: "tokenType", comparator: "equals", value: "refresh" },
292
+ ],
293
+ })) {
294
+ refreshRecord = t;
295
+ break;
296
+ }
297
+ if (!refreshRecord) {
298
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
299
+ status: 400, headers: { "content-type": "application/json" },
300
+ });
301
+ }
302
+ if (refreshRecord.revokedAt) {
303
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "token revoked" }), {
304
+ status: 400, headers: { "content-type": "application/json" },
305
+ });
306
+ }
307
+ if (new Date(refreshRecord.expiresAt) < new Date()) {
308
+ return new Response(JSON.stringify({ error: "invalid_grant", error_description: "refresh token expired" }), {
309
+ status: 400, headers: { "content-type": "application/json" },
310
+ });
311
+ }
312
+ if (clientId && refreshRecord.clientId !== clientId) {
313
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
314
+ status: 400, headers: { "content-type": "application/json" },
315
+ });
316
+ }
317
+ // Rotate: revoke old refresh token, issue new pair
318
+ await databases.flair.OAuthToken.put({
319
+ ...refreshRecord,
320
+ revokedAt: nowISO(),
321
+ });
322
+ return this.issueTokenPair(refreshRecord.clientId, refreshRecord.principalId, refreshRecord.scope);
323
+ }
324
+ async issueTokenPair(clientId, principalId, scope) {
325
+ const now = nowISO();
326
+ const accessTokenRaw = randomToken("flair_at_");
327
+ const refreshTokenRaw = randomToken("flair_rt_");
328
+ const accessId = `at_${randomBytes(8).toString("hex")}`;
329
+ const refreshId = `rt_${randomBytes(8).toString("hex")}`;
330
+ await databases.flair.OAuthToken.put({
331
+ id: accessId,
332
+ tokenHash: sha256(accessTokenRaw),
333
+ tokenType: "access",
334
+ clientId,
335
+ principalId,
336
+ scope,
337
+ expiresAt: futureISO(ACCESS_TOKEN_TTL_MS),
338
+ createdAt: now,
339
+ });
340
+ await databases.flair.OAuthToken.put({
341
+ id: refreshId,
342
+ tokenHash: sha256(refreshTokenRaw),
343
+ tokenType: "refresh",
344
+ clientId,
345
+ principalId,
346
+ scope,
347
+ expiresAt: futureISO(REFRESH_TOKEN_TTL_MS),
348
+ parentTokenId: accessId,
349
+ createdAt: now,
350
+ });
351
+ return {
352
+ access_token: accessTokenRaw,
353
+ token_type: "Bearer",
354
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
355
+ refresh_token: refreshTokenRaw,
356
+ scope,
357
+ };
358
+ }
359
+ }
360
+ // ─── Revocation endpoint ─────────────────────────────────────────────────────
361
+ export class OAuthRevoke extends Resource {
362
+ async post(data) {
363
+ const token = data?.token;
364
+ if (!token) {
365
+ return new Response(JSON.stringify({ error: "invalid_request" }), {
366
+ status: 400, headers: { "content-type": "application/json" },
367
+ });
368
+ }
369
+ const tokenHash = sha256(token);
370
+ for await (const t of databases.flair.OAuthToken.search({
371
+ conditions: [{ attribute: "tokenHash", comparator: "equals", value: tokenHash }],
372
+ })) {
373
+ await databases.flair.OAuthToken.put({
374
+ ...t,
375
+ revokedAt: nowISO(),
376
+ });
377
+ }
378
+ // RFC 7009: always return 200 regardless of whether token was found
379
+ return {};
380
+ }
381
+ }
@@ -0,0 +1,96 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
3
+ /**
4
+ * Relationship resource — entity-to-entity relationships with temporal validity.
5
+ *
6
+ * Enables knowledge graph queries like:
7
+ * - "Who manages project X?" (active relationships)
8
+ * - "Who was team lead in Q1?" (historical, validFrom/validTo bounded)
9
+ * - "What changed about Nathan's role?" (all relationships for a subject, ordered by time)
10
+ *
11
+ * Relationships are scoped by agentId for multi-agent isolation.
12
+ * Admin agents can query across all agents.
13
+ */
14
+ export class Relationship extends databases.flair.Relationship {
15
+ async search(query) {
16
+ const ctx = this.getContext?.();
17
+ const request = ctx?.request ?? ctx;
18
+ const authAgent = request?.tpsAgent;
19
+ const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
20
+ if (!authAgent || isAdminAgent) {
21
+ return super.search(query);
22
+ }
23
+ // Non-admin: scope to own relationships
24
+ const agentCondition = { attribute: "agentId", comparator: "equals", value: authAgent };
25
+ if (!query?.conditions) {
26
+ return super.search({ conditions: [agentCondition], ...(query || {}) });
27
+ }
28
+ return super.search({
29
+ ...query,
30
+ conditions: [agentCondition, { conditions: query.conditions, operator: query.operator || "and" }],
31
+ operator: "and",
32
+ });
33
+ }
34
+ async put(content) {
35
+ const ctx = this.getContext?.();
36
+ const request = ctx?.request ?? ctx;
37
+ const authAgent = request?.tpsAgent;
38
+ if (!authAgent) {
39
+ return new Response(JSON.stringify({ error: "authentication required" }), {
40
+ status: 401, headers: { "content-type": "application/json" },
41
+ });
42
+ }
43
+ const rl = checkRateLimit(authAgent);
44
+ if (!rl.allowed)
45
+ return rateLimitResponse(rl.retryAfterMs, "relationship");
46
+ // Validate required fields
47
+ if (!content.subject || typeof content.subject !== "string") {
48
+ return new Response(JSON.stringify({ error: "subject is required (string)" }), {
49
+ status: 400, headers: { "content-type": "application/json" },
50
+ });
51
+ }
52
+ if (!content.predicate || typeof content.predicate !== "string") {
53
+ return new Response(JSON.stringify({ error: "predicate is required (string)" }), {
54
+ status: 400, headers: { "content-type": "application/json" },
55
+ });
56
+ }
57
+ if (!content.object || typeof content.object !== "string") {
58
+ return new Response(JSON.stringify({ error: "object is required (string)" }), {
59
+ status: 400, headers: { "content-type": "application/json" },
60
+ });
61
+ }
62
+ // Normalize
63
+ const now = new Date().toISOString();
64
+ content.agentId = authAgent;
65
+ content.subject = content.subject.toLowerCase();
66
+ content.predicate = content.predicate.toLowerCase();
67
+ content.object = content.object.toLowerCase();
68
+ content.createdAt = content.createdAt || now;
69
+ content.updatedAt = now;
70
+ content.validFrom = content.validFrom || now;
71
+ // validTo left as null/undefined for active relationships
72
+ content.confidence = content.confidence ?? 1.0;
73
+ return super.put(content);
74
+ }
75
+ async delete(_) {
76
+ const ctx = this.getContext?.();
77
+ const request = ctx?.request ?? ctx;
78
+ const authAgent = request?.tpsAgent;
79
+ const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
80
+ if (!authAgent) {
81
+ return new Response(JSON.stringify({ error: "authentication required" }), {
82
+ status: 401, headers: { "content-type": "application/json" },
83
+ });
84
+ }
85
+ // Non-admin: verify ownership before delete
86
+ if (!isAdminAgent) {
87
+ const existing = await super.get();
88
+ if (existing?.agentId && existing.agentId !== authAgent) {
89
+ return new Response(JSON.stringify({ error: "cannot delete another agent's relationship" }), {
90
+ status: 403, headers: { "content-type": "application/json" },
91
+ });
92
+ }
93
+ }
94
+ return super.delete(_);
95
+ }
96
+ }
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { getEmbedding, getMode } from "./embeddings-provider.js";
3
3
  import { patchRecord } from "./table-helpers.js";
4
4
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
5
+ import { wrapUntrusted } from "./content-safety.js";
5
6
  // ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
6
7
  const DURABILITY_WEIGHTS = {
7
8
  permanent: 1.0,
@@ -45,7 +46,7 @@ function distanceToSimilarity(distance) {
45
46
  const CANDIDATE_MULTIPLIER = 5;
46
47
  export class SemanticSearch extends Resource {
47
48
  async post(data) {
48
- const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since } = data || {};
49
+ const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
49
50
  // Rate limiting — use authenticated agent ID from request context, not client-supplied body
50
51
  const rateLimitAgent = this.request?.headers?.get?.("x-tps-agent")
51
52
  ?? this.request?.tpsAgent;
@@ -171,7 +172,7 @@ export class SemanticSearch extends Resource {
171
172
  "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
172
173
  "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
173
174
  "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
174
- "$distance"],
175
+ "validFrom", "validTo", "_safetyFlags", "$distance"],
175
176
  limit: candidateLimit,
176
177
  };
177
178
  if (conditions.length > 0) {
@@ -182,6 +183,11 @@ export class SemanticSearch extends Resource {
182
183
  continue;
183
184
  if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
184
185
  continue;
186
+ // Temporal validity: if asOf is specified, only include memories valid at that point
187
+ if (asOf && record.validFrom && record.validFrom > asOf)
188
+ continue;
189
+ if (asOf && record.validTo && record.validTo <= asOf)
190
+ continue;
185
191
  const semanticScore = distanceToSimilarity(record.$distance ?? 1);
186
192
  let keywordHit = false;
187
193
  if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
@@ -192,11 +198,14 @@ export class SemanticSearch extends Resource {
192
198
  if (temporalBoost > 1.0)
193
199
  finalScore *= temporalBoost;
194
200
  const { $distance, ...rest } = record;
201
+ const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
202
+ const source = record.agentId !== agentId ? record.agentId : undefined;
195
203
  results.push({
196
204
  ...rest,
205
+ content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
197
206
  _score: Math.round(finalScore * 1000) / 1000,
198
207
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
199
- _source: record.agentId !== agentId ? record.agentId : undefined,
208
+ _source: source,
200
209
  });
201
210
  }
202
211
  }
@@ -210,6 +219,10 @@ export class SemanticSearch extends Resource {
210
219
  continue;
211
220
  if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
212
221
  continue;
222
+ if (asOf && record.validFrom && record.validFrom > asOf)
223
+ continue;
224
+ if (asOf && record.validTo && record.validTo <= asOf)
225
+ continue;
213
226
  let keywordHit = false;
214
227
  if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
215
228
  keywordHit = true;
@@ -221,11 +234,14 @@ export class SemanticSearch extends Resource {
221
234
  let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
222
235
  if (temporalBoost > 1.0)
223
236
  finalScore *= temporalBoost;
237
+ const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
238
+ const source = record.agentId !== agentId ? record.agentId : undefined;
224
239
  results.push({
225
240
  ...rest,
241
+ content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
226
242
  _score: Math.round(finalScore * 1000) / 1000,
227
243
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
228
- _source: record.agentId !== agentId ? record.agentId : undefined,
244
+ _source: source,
229
245
  });
230
246
  }
231
247
  }