@tpsdev-ai/flair 0.2.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.
Files changed (64) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +246 -0
  3. package/SECURITY.md +116 -0
  4. package/config.yaml +16 -0
  5. package/dist/resources/A2AAdapter.js +474 -0
  6. package/dist/resources/Agent.js +9 -0
  7. package/dist/resources/AgentCard.js +45 -0
  8. package/dist/resources/AgentSeed.js +111 -0
  9. package/dist/resources/IngestEvents.js +149 -0
  10. package/dist/resources/Integration.js +13 -0
  11. package/dist/resources/IssueTokens.js +19 -0
  12. package/dist/resources/Memory.js +122 -0
  13. package/dist/resources/MemoryBootstrap.js +263 -0
  14. package/dist/resources/MemoryConsolidate.js +105 -0
  15. package/dist/resources/MemoryFeed.js +41 -0
  16. package/dist/resources/MemoryReflect.js +105 -0
  17. package/dist/resources/OrgEvent.js +43 -0
  18. package/dist/resources/OrgEventCatchup.js +65 -0
  19. package/dist/resources/OrgEventMaintenance.js +29 -0
  20. package/dist/resources/SemanticSearch.js +147 -0
  21. package/dist/resources/SkillScan.js +101 -0
  22. package/dist/resources/Soul.js +9 -0
  23. package/dist/resources/SoulFeed.js +12 -0
  24. package/dist/resources/WorkspaceLatest.js +45 -0
  25. package/dist/resources/WorkspaceState.js +76 -0
  26. package/dist/resources/auth-middleware.js +470 -0
  27. package/dist/resources/embeddings-provider.js +127 -0
  28. package/dist/resources/embeddings.js +42 -0
  29. package/dist/resources/health.js +6 -0
  30. package/dist/resources/memory-feed-lib.js +15 -0
  31. package/dist/resources/table-helpers.js +35 -0
  32. package/package.json +62 -0
  33. package/resources/A2AAdapter.ts +510 -0
  34. package/resources/Agent.ts +10 -0
  35. package/resources/AgentCard.ts +65 -0
  36. package/resources/AgentSeed.ts +119 -0
  37. package/resources/IngestEvents.ts +189 -0
  38. package/resources/Integration.ts +14 -0
  39. package/resources/IssueTokens.ts +29 -0
  40. package/resources/Memory.ts +138 -0
  41. package/resources/MemoryBootstrap.ts +283 -0
  42. package/resources/MemoryConsolidate.ts +121 -0
  43. package/resources/MemoryFeed.ts +48 -0
  44. package/resources/MemoryReflect.ts +122 -0
  45. package/resources/OrgEvent.ts +63 -0
  46. package/resources/OrgEventCatchup.ts +89 -0
  47. package/resources/OrgEventMaintenance.ts +37 -0
  48. package/resources/SemanticSearch.ts +157 -0
  49. package/resources/SkillScan.ts +146 -0
  50. package/resources/Soul.ts +10 -0
  51. package/resources/SoulFeed.ts +15 -0
  52. package/resources/WorkspaceLatest.ts +66 -0
  53. package/resources/WorkspaceState.ts +102 -0
  54. package/resources/auth-middleware.ts +502 -0
  55. package/resources/embeddings-provider.ts +144 -0
  56. package/resources/embeddings.ts +28 -0
  57. package/resources/health.ts +7 -0
  58. package/resources/memory-feed-lib.ts +22 -0
  59. package/resources/table-helpers.ts +46 -0
  60. package/schemas/agent.graphql +22 -0
  61. package/schemas/event.graphql +12 -0
  62. package/schemas/memory.graphql +50 -0
  63. package/schemas/schema.graphql +41 -0
  64. package/schemas/workspace.graphql +14 -0
@@ -0,0 +1,65 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+
3
+ type SoulLike = {
4
+ key?: string;
5
+ value?: string;
6
+ kind?: string;
7
+ content?: string;
8
+ };
9
+
10
+ function readSoulKind(entry: SoulLike): string {
11
+ return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
12
+ }
13
+
14
+ function readSoulContent(entry: SoulLike): string {
15
+ return String(entry.content ?? entry.value ?? "").trim();
16
+ }
17
+
18
+ export class AgentCard extends Resource {
19
+ async get(pathInfo?: any) {
20
+ const agentId =
21
+ (typeof pathInfo === "string" ? pathInfo : null) ??
22
+ (this as any).getId?.() ??
23
+ null;
24
+
25
+ if (!agentId) {
26
+ return new Response(
27
+ JSON.stringify({ error: "agentId required in path: GET /AgentCard/{agentId}" }),
28
+ { status: 400, headers: { "Content-Type": "application/json" } },
29
+ );
30
+ }
31
+
32
+ const agent = await (databases as any).flair.Agent.get(agentId).catch(() => null);
33
+ if (!agent) {
34
+ return new Response(JSON.stringify({ error: "agent_not_found", agentId }), {
35
+ status: 404,
36
+ headers: { "Content-Type": "application/json" },
37
+ });
38
+ }
39
+
40
+ const souls: SoulLike[] = [];
41
+ for await (const row of (databases as any).flair.Soul.search()) {
42
+ if (row?.agentId === agentId) souls.push(row);
43
+ }
44
+
45
+ const descriptionEntry =
46
+ souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s)) ??
47
+ souls.find((s) => readSoulContent(s));
48
+
49
+ const skills = souls
50
+ .filter((s) => readSoulKind(s) === "capability")
51
+ .map((s) => readSoulContent(s))
52
+ .filter(Boolean);
53
+
54
+ return {
55
+ name: String(agent.name ?? agent.id ?? agentId),
56
+ description: descriptionEntry ? readSoulContent(descriptionEntry) : "",
57
+ url: String(agent.url ?? ""),
58
+ version: String(agent.version ?? "1.0.0"),
59
+ capabilities: agent.capabilities && typeof agent.capabilities === "object" ? agent.capabilities : {},
60
+ skills,
61
+ defaultInputModes: ["text"],
62
+ defaultOutputModes: ["text"],
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * POST /AgentSeed
3
+ *
4
+ * Auto-seeds a new agent with soul entries and starter memories.
5
+ * Called by `tps agent create` after local key generation.
6
+ *
7
+ * Request:
8
+ * agentId string — agent identifier
9
+ * displayName string? — human-readable name (defaults to agentId)
10
+ * role string? — "admin" | "agent" (default: "agent")
11
+ * soulTemplate object? — key:value pairs for Soul table (merged with defaults)
12
+ * starterMemories array? — [{content, tags?, durability?}] (defaults if omitted)
13
+ *
14
+ * Response:
15
+ * { agent, soulEntries, memories }
16
+ *
17
+ * Auth: admin only.
18
+ */
19
+
20
+ import { Resource, databases } from "@harperfast/harper";
21
+ import { isAdmin } from "./auth-middleware.js";
22
+
23
+ const DEFAULT_SOUL_KEYS = (agentId: string, displayName: string, role: string, now: string) => ({
24
+ name: displayName,
25
+ role,
26
+ created: now,
27
+ status: "active",
28
+ });
29
+
30
+ const DEFAULT_MEMORIES = (agentId: string, now: string) => [
31
+ {
32
+ content: `Agent ${agentId} initialized. No prior context.`,
33
+ tags: ["onboarding", "system"],
34
+ durability: "persistent",
35
+ },
36
+ ];
37
+
38
+ export class AgentSeed extends Resource {
39
+ async post(data: any) {
40
+ const actorId = (this as any).request?.tpsAgent;
41
+ if (!actorId || !(await isAdmin(actorId))) {
42
+ return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403 });
43
+ }
44
+
45
+ const { agentId, displayName, role = "agent", soulTemplate, starterMemories } = data || {};
46
+ if (!agentId) return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
47
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(agentId)) {
48
+ return new Response(JSON.stringify({ error: "invalid agentId" }), { status: 400 });
49
+ }
50
+
51
+ const now = new Date().toISOString();
52
+ const name = displayName || agentId;
53
+
54
+ // ── Agent record ──────────────────────────────────────────────────────────
55
+ const existingAgent = await (databases as any).flair.Agent.get(agentId).catch(() => null);
56
+ let agent = existingAgent;
57
+ if (!existingAgent) {
58
+ agent = { id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now };
59
+ await (databases as any).flair.Agent.put(agent);
60
+ }
61
+
62
+ // ── Soul entries ──────────────────────────────────────────────────────────
63
+ const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
64
+ const merged = { ...defaults, ...(soulTemplate || {}) };
65
+ const soulEntries: any[] = [];
66
+
67
+ for (const [key, value] of Object.entries(merged)) {
68
+ const id = `${agentId}:${key}`;
69
+ const existing = await (databases as any).flair.Soul.get(id);
70
+ if (existing) {
71
+ soulEntries.push(existing); // skip — don't overwrite existing soul entries
72
+ continue;
73
+ }
74
+ const entry = { id, agentId, key, value: String(value), durability: "permanent", createdAt: now, updatedAt: now };
75
+ await (databases as any).flair.Soul.put(entry);
76
+ soulEntries.push(entry);
77
+ }
78
+
79
+ // ── Starter memories ──────────────────────────────────────────────────────
80
+ const memDefs = starterMemories && starterMemories.length > 0
81
+ ? starterMemories
82
+ : DEFAULT_MEMORIES(agentId, now);
83
+
84
+ const memories: any[] = [];
85
+ // Only seed memories if this is a first-time seed (none tagged onboarding yet)
86
+ const hasOnboardingMemory = await (async () => {
87
+ for await (const m of (databases as any).flair.Memory.search()) {
88
+ if (m.agentId === agentId && (m.tags ?? []).includes("onboarding")) return true;
89
+ }
90
+ return false;
91
+ })();
92
+ if (hasOnboardingMemory) {
93
+ // Re-seed: return existing onboarding memories without writing new ones
94
+ for await (const m of (databases as any).flair.Memory.search()) {
95
+ if (m.agentId === agentId && (m.tags ?? []).includes("onboarding")) memories.push(m);
96
+ }
97
+ } else {
98
+ for (let i = 0; i < memDefs.length; i++) {
99
+ const def = memDefs[i];
100
+ const id = `seed-${agentId}-${i}-${Date.now()}`;
101
+ const record = {
102
+ id,
103
+ agentId,
104
+ content: def.content,
105
+ durability: def.durability ?? "persistent",
106
+ tags: def.tags ?? ["onboarding"],
107
+ source: "seed",
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ archived: false,
111
+ };
112
+ await (databases as any).flair.Memory.put(record);
113
+ memories.push(record);
114
+ }
115
+ } // end !hasOnboardingMemory
116
+
117
+ return { agent, soulEntries, memories };
118
+ }
119
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * IngestEvents.ts — Observatory ingestion endpoint.
3
+ *
4
+ * POST /IngestEvents
5
+ * Auth: TPS-Ed25519 signed by the office's private key (verified against
6
+ * ObsOffice.publicKey stored at registration time).
7
+ *
8
+ * Body: {
9
+ * officeId: string;
10
+ * events: OrgEventRecord[];
11
+ * agents: AgentStatus[];
12
+ * syncedAt: string;
13
+ * }
14
+ *
15
+ * Actions:
16
+ * 1. Verify Ed25519 signature against stored office public key
17
+ * 2. Upsert ObsAgentSnapshot for each agent
18
+ * 3. Insert ObsEventFeed for each new event (30-day TTL)
19
+ * 4. Update ObsOffice.lastSeen + agentCount
20
+ *
21
+ * Rate limit: 1 call / 10s per office (enforced by createdAt delta check)
22
+ * Batch limit: 100 events per call
23
+ */
24
+
25
+ import { Resource, databases } from "@harperfast/harper";
26
+ import { createPublicKey, verify } from "node:crypto";
27
+
28
+ const BATCH_LIMIT = 100;
29
+ const RATE_LIMIT_MS = 10_000;
30
+ const EVENT_TTL_DAYS = 30;
31
+
32
+ interface OrgEventRecord {
33
+ id: string;
34
+ kind: string;
35
+ authorId: string;
36
+ summary: string;
37
+ refId?: string;
38
+ scope?: string;
39
+ targetIds?: string[];
40
+ createdAt: string;
41
+ }
42
+
43
+ interface AgentStatus {
44
+ agentId: string;
45
+ name?: string;
46
+ role?: string;
47
+ status?: string;
48
+ model?: string;
49
+ lastSeen?: string;
50
+ }
51
+
52
+ interface IngestPayload {
53
+ officeId: string;
54
+ events: OrgEventRecord[];
55
+ agents: AgentStatus[];
56
+ syncedAt: string;
57
+ }
58
+
59
+ function verifyEd25519Signature(
60
+ publicKeyHex: string,
61
+ authHeader: string,
62
+ officeId: string,
63
+ ): boolean {
64
+ try {
65
+ // Header format: TPS-Ed25519 officeId:ts:nonce:sig
66
+ const prefix = "TPS-Ed25519 ";
67
+ if (!authHeader.startsWith(prefix)) return false;
68
+ const parts = authHeader.slice(prefix.length).split(":");
69
+ if (parts.length < 4) return false;
70
+ const [id, ts, nonce, ...sigParts] = parts;
71
+ const sig = sigParts.join(":");
72
+ if (id !== officeId) return false;
73
+
74
+ // Replay protection: reject signatures older than 5 minutes
75
+ const age = Date.now() - Number(ts);
76
+ if (age > 5 * 60 * 1000 || age < -30_000) return false;
77
+
78
+ const pubKeyBytes = Buffer.from(publicKeyHex.replace(/=\s*/g, ""), "hex");
79
+ const spkiHeader = Buffer.from("302a300506032b6570032100", "hex");
80
+ const pubKey = createPublicKey({ key: Buffer.concat([spkiHeader, pubKeyBytes]), format: "der", type: "spki" });
81
+
82
+ const payload = Buffer.from(`${id}:${ts}:${nonce}:POST:/IngestEvents`);
83
+ const sigBuf = Buffer.from(sig, "base64");
84
+ return verify(null, payload, pubKey, sigBuf);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export class IngestEvents extends Resource {
91
+ async post(body: unknown, context?: unknown) {
92
+ const request = (this as any).request;
93
+ const authHeader: string | undefined = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
94
+
95
+ // Parse and validate body
96
+ let payload: IngestPayload;
97
+ try {
98
+ payload = (typeof body === "string" ? JSON.parse(body) : body) as IngestPayload;
99
+ } catch {
100
+ return new Response(JSON.stringify({ error: "invalid JSON" }), { status: 400, headers: { "Content-Type": "application/json" } });
101
+ }
102
+
103
+ const { officeId, events = [], agents = [], syncedAt } = payload;
104
+ if (!officeId) {
105
+ return new Response(JSON.stringify({ error: "officeId required" }), { status: 400, headers: { "Content-Type": "application/json" } });
106
+ }
107
+
108
+ // Look up the office
109
+ const office = await (databases as any).flair.ObsOffice.get(officeId).catch(() => null);
110
+ if (!office) {
111
+ return new Response(JSON.stringify({ error: "office not registered — POST /ObsOffice first" }), { status: 403, headers: { "Content-Type": "application/json" } });
112
+ }
113
+
114
+ // Verify Ed25519 signature
115
+ if (!authHeader || !verifyEd25519Signature(String(office.publicKey), authHeader, officeId)) {
116
+ return new Response(JSON.stringify({ error: "invalid signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
117
+ }
118
+
119
+ // Rate limit check
120
+ if (office.lastSeen) {
121
+ const msSinceLastSync = Date.now() - new Date(office.lastSeen).getTime();
122
+ if (msSinceLastSync < RATE_LIMIT_MS) {
123
+ return new Response(JSON.stringify({ error: "rate limit: 1 call per 10s" }), { status: 429, headers: { "Content-Type": "application/json" } });
124
+ }
125
+ }
126
+
127
+ // Batch limit
128
+ if (events.length > BATCH_LIMIT) {
129
+ return new Response(JSON.stringify({ error: `batch limit: max ${BATCH_LIMIT} events` }), { status: 400, headers: { "Content-Type": "application/json" } });
130
+ }
131
+
132
+ const now = new Date().toISOString();
133
+ const expiresAt = new Date(Date.now() + EVENT_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
134
+
135
+ // Upsert agent snapshots
136
+ for (const agent of agents) {
137
+ if (!agent.agentId) continue;
138
+ const snapshotId = `${officeId}:${agent.agentId}`;
139
+ await (databases as any).flair.ObsAgentSnapshot.put({
140
+ id: snapshotId,
141
+ officeId,
142
+ agentId: agent.agentId,
143
+ name: agent.name ?? agent.agentId,
144
+ role: agent.role,
145
+ status: agent.status ?? "unknown",
146
+ model: agent.model,
147
+ lastActivity: agent.lastSeen ?? now,
148
+ lastHeartbeat: now,
149
+ updatedAt: now,
150
+ }).catch((e: Error) => console.warn(`[IngestEvents] snapshot upsert failed for ${snapshotId}: ${e.message}`));
151
+ }
152
+
153
+ // Insert event feed entries (skip duplicates)
154
+ let inserted = 0;
155
+ for (const ev of events) {
156
+ if (!ev.id || !ev.kind) continue;
157
+ const feedId = `${officeId}:${ev.id}`;
158
+ const existing = await (databases as any).flair.ObsEventFeed.get(feedId).catch(() => null);
159
+ if (existing) continue;
160
+ await (databases as any).flair.ObsEventFeed.put({
161
+ id: feedId,
162
+ officeId,
163
+ kind: ev.kind,
164
+ authorId: ev.authorId,
165
+ summary: ev.summary,
166
+ refId: ev.refId,
167
+ scope: ev.scope,
168
+ createdAt: ev.createdAt,
169
+ receivedAt: now,
170
+ expiresAt,
171
+ }).catch((e: Error) => console.warn(`[IngestEvents] event insert failed for ${feedId}: ${e.message}`));
172
+ inserted++;
173
+ }
174
+
175
+ // Update office lastSeen + agentCount
176
+ await (databases as any).flair.ObsOffice.put({
177
+ ...office,
178
+ status: "online",
179
+ lastSeen: now,
180
+ agentCount: agents.length,
181
+ updatedAt: now,
182
+ }).catch(() => {});
183
+
184
+ return new Response(JSON.stringify({ ok: true, events: inserted, agents: agents.length }), {
185
+ status: 200,
186
+ headers: { "Content-Type": "application/json" },
187
+ });
188
+ }
189
+ }
@@ -0,0 +1,14 @@
1
+ import { databases } from "@harperfast/harper";
2
+
3
+ export class Integration extends (databases as any).flair.Integration {
4
+ async post(content: any, context?: any) {
5
+ // S31-A: API never accepts plaintext credentials.
6
+ if (typeof content?.credential === "string" || typeof content?.token === "string") {
7
+ return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
8
+ status: 400,
9
+ headers: { "Content-Type": "application/json" },
10
+ });
11
+ }
12
+ return super.post(content, context);
13
+ }
14
+ }
@@ -0,0 +1,29 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+
3
+ export class IssueTokens extends Resource {
4
+ static loadAsInstance = false;
5
+
6
+ async get(_target: unknown) {
7
+ const { refresh_token: refreshToken, operation_token: jwt } =
8
+ await (databases as any).system.hdb_user.operation(
9
+ { operation: "create_authentication_tokens" },
10
+ this.getContext(),
11
+ );
12
+ return { refreshToken, jwt };
13
+ }
14
+
15
+ async post(_target: unknown, data: any) {
16
+ if (!data?.username || !data?.password) {
17
+ throw new Error("username and password are required");
18
+ }
19
+
20
+ const { refresh_token: refreshToken, operation_token: jwt } =
21
+ await (databases as any).system.hdb_user.operation({
22
+ operation: "create_authentication_tokens",
23
+ username: data.username,
24
+ password: data.password,
25
+ });
26
+
27
+ return { refreshToken, jwt };
28
+ }
29
+ }
@@ -0,0 +1,138 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { patchRecord } from "./table-helpers.js";
3
+ import { isAdmin } from "./auth-middleware.js";
4
+
5
+ export class Memory extends (databases as any).flair.Memory {
6
+ /**
7
+ * Override search() to scope collection GETs by authenticated agent.
8
+ *
9
+ * Security Critical: the agentId condition is wrapped as the outermost
10
+ * `and` block so user-supplied query operators cannot bypass it via
11
+ * boolean injection (e.g. [..., "or", { wildcard }]).
12
+ *
13
+ * Admin agents and unauthenticated internal calls pass through unfiltered.
14
+ * Non-admin calls also check MemoryGrant to include granted memories.
15
+ */
16
+ async search(query?: any) {
17
+ // Access request context via Harper's Resource instance context
18
+ const ctx = (this as any).getContext?.();
19
+ const request = ctx?.request ?? ctx;
20
+ const authAgent: string | undefined = request?.tpsAgent;
21
+ const isAdminAgent: boolean = request?.tpsAgentIsAdmin ?? false;
22
+
23
+ // No auth context (internal admin call) or admin agent — unfiltered
24
+ if (!authAgent || isAdminAgent) {
25
+ return super.search(query);
26
+ }
27
+
28
+ // Collect agentIds this agent may read: own + any granted owners
29
+ const allowedOwners: string[] = [authAgent];
30
+ try {
31
+ for await (const grant of (databases as any).flair.MemoryGrant.search({
32
+ conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
33
+ })) {
34
+ if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
35
+ allowedOwners.push(grant.ownerId);
36
+ }
37
+ }
38
+ } catch { /* MemoryGrant table not yet populated — ignore */ }
39
+
40
+ // Build the agentId scope condition
41
+ const agentIdCondition: any = allowedOwners.length === 1
42
+ ? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
43
+ : { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
44
+
45
+ // Harper passes `query` as a RequestTarget (extends URLSearchParams) with pathname, id, etc.
46
+ // Table.search() reads `target.conditions` from it. We inject our scope condition there.
47
+ if (query && typeof query === "object" && !Array.isArray(query)) {
48
+ const existing = query.conditions ?? [];
49
+ query.conditions = Array.isArray(existing)
50
+ ? [agentIdCondition, ...existing]
51
+ : [agentIdCondition, existing];
52
+ return super.search(query);
53
+ }
54
+
55
+ // Fallback: plain array or no query (internal calls)
56
+ const conditions = Array.isArray(query) && query.length > 0
57
+ ? [agentIdCondition, ...query]
58
+ : [agentIdCondition];
59
+ return super.search(conditions);
60
+ }
61
+
62
+ async post(content: any, context?: any) {
63
+ content.durability ||= "standard";
64
+ content.createdAt = new Date().toISOString();
65
+ content.updatedAt = content.createdAt;
66
+ content.archived = content.archived ?? false;
67
+
68
+ // Validate derivedFrom source IDs exist (best-effort, non-blocking)
69
+ if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
70
+ const now = content.createdAt;
71
+ for (const sourceId of content.derivedFrom) {
72
+ try {
73
+ const src = await (databases as any).flair.Memory.get(sourceId);
74
+ if (src) {
75
+ patchRecord((databases as any).flair.Memory, sourceId, { lastReflected: now }).catch(() => {});
76
+ }
77
+ } catch {}
78
+ }
79
+ }
80
+
81
+ // supersedes: optional reference to the ID of the memory this one replaces
82
+ if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
83
+ return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
84
+ status: 400, headers: { "Content-Type": "application/json" },
85
+ });
86
+ }
87
+
88
+ if (content.durability === "ephemeral" && !content.expiresAt) {
89
+ const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
90
+ content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
91
+ }
92
+
93
+ return super.post(content);
94
+ }
95
+
96
+ async put(content: any) {
97
+ const now = new Date().toISOString();
98
+ content.updatedAt = now;
99
+
100
+ // If archiving, record who + when
101
+ if (content.archived === true && !content.archivedAt) {
102
+ content.archivedAt = now;
103
+ // archivedBy should be set by the caller (CLI stamps req.tpsAgent via query param)
104
+ }
105
+
106
+ // If approving promotion, record timestamp
107
+ if (content.promotionStatus === "approved" && !content.promotedAt) {
108
+ content.promotedAt = now;
109
+ }
110
+
111
+ // Upgrade to permanent when approved
112
+ if (content.promotionStatus === "approved") {
113
+ content.durability = "permanent";
114
+ }
115
+
116
+ return super.put(content);
117
+ }
118
+
119
+ async delete(id: any) {
120
+ const record = await this.get(id);
121
+ if (!record) return super.delete(id);
122
+
123
+ if (record.durability === "permanent") {
124
+ // Middleware already guards this for non-admins, but belt-and-suspenders
125
+ const ctx = (this as any).getContext?.();
126
+ const request = ctx?.request ?? ctx;
127
+ const actorId = request?.tpsAgent;
128
+ if (actorId && !(await isAdmin(actorId))) {
129
+ return new Response(JSON.stringify({ error: "permanent_memory_cannot_be_deleted_by_non_admin" }), {
130
+ status: 403,
131
+ headers: { "Content-Type": "application/json" },
132
+ });
133
+ }
134
+ }
135
+
136
+ return super.delete(id);
137
+ }
138
+ }