@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,149 @@
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
+ import { Resource, databases } from "@harperfast/harper";
25
+ import { createPublicKey, verify } from "node:crypto";
26
+ const BATCH_LIMIT = 100;
27
+ const RATE_LIMIT_MS = 10_000;
28
+ const EVENT_TTL_DAYS = 30;
29
+ function verifyEd25519Signature(publicKeyHex, authHeader, officeId) {
30
+ try {
31
+ // Header format: TPS-Ed25519 officeId:ts:nonce:sig
32
+ const prefix = "TPS-Ed25519 ";
33
+ if (!authHeader.startsWith(prefix))
34
+ return false;
35
+ const parts = authHeader.slice(prefix.length).split(":");
36
+ if (parts.length < 4)
37
+ return false;
38
+ const [id, ts, nonce, ...sigParts] = parts;
39
+ const sig = sigParts.join(":");
40
+ if (id !== officeId)
41
+ return false;
42
+ // Replay protection: reject signatures older than 5 minutes
43
+ const age = Date.now() - Number(ts);
44
+ if (age > 5 * 60 * 1000 || age < -30_000)
45
+ return false;
46
+ const pubKeyBytes = Buffer.from(publicKeyHex.replace(/=\s*/g, ""), "hex");
47
+ const spkiHeader = Buffer.from("302a300506032b6570032100", "hex");
48
+ const pubKey = createPublicKey({ key: Buffer.concat([spkiHeader, pubKeyBytes]), format: "der", type: "spki" });
49
+ const payload = Buffer.from(`${id}:${ts}:${nonce}:POST:/IngestEvents`);
50
+ const sigBuf = Buffer.from(sig, "base64");
51
+ return verify(null, payload, pubKey, sigBuf);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ export class IngestEvents extends Resource {
58
+ async post(body, context) {
59
+ const request = this.request;
60
+ const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
61
+ // Parse and validate body
62
+ let payload;
63
+ try {
64
+ payload = (typeof body === "string" ? JSON.parse(body) : body);
65
+ }
66
+ catch {
67
+ return new Response(JSON.stringify({ error: "invalid JSON" }), { status: 400, headers: { "Content-Type": "application/json" } });
68
+ }
69
+ const { officeId, events = [], agents = [], syncedAt } = payload;
70
+ if (!officeId) {
71
+ return new Response(JSON.stringify({ error: "officeId required" }), { status: 400, headers: { "Content-Type": "application/json" } });
72
+ }
73
+ // Look up the office
74
+ const office = await databases.flair.ObsOffice.get(officeId).catch(() => null);
75
+ if (!office) {
76
+ return new Response(JSON.stringify({ error: "office not registered — POST /ObsOffice first" }), { status: 403, headers: { "Content-Type": "application/json" } });
77
+ }
78
+ // Verify Ed25519 signature
79
+ if (!authHeader || !verifyEd25519Signature(String(office.publicKey), authHeader, officeId)) {
80
+ return new Response(JSON.stringify({ error: "invalid signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
81
+ }
82
+ // Rate limit check
83
+ if (office.lastSeen) {
84
+ const msSinceLastSync = Date.now() - new Date(office.lastSeen).getTime();
85
+ if (msSinceLastSync < RATE_LIMIT_MS) {
86
+ return new Response(JSON.stringify({ error: "rate limit: 1 call per 10s" }), { status: 429, headers: { "Content-Type": "application/json" } });
87
+ }
88
+ }
89
+ // Batch limit
90
+ if (events.length > BATCH_LIMIT) {
91
+ return new Response(JSON.stringify({ error: `batch limit: max ${BATCH_LIMIT} events` }), { status: 400, headers: { "Content-Type": "application/json" } });
92
+ }
93
+ const now = new Date().toISOString();
94
+ const expiresAt = new Date(Date.now() + EVENT_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
95
+ // Upsert agent snapshots
96
+ for (const agent of agents) {
97
+ if (!agent.agentId)
98
+ continue;
99
+ const snapshotId = `${officeId}:${agent.agentId}`;
100
+ await databases.flair.ObsAgentSnapshot.put({
101
+ id: snapshotId,
102
+ officeId,
103
+ agentId: agent.agentId,
104
+ name: agent.name ?? agent.agentId,
105
+ role: agent.role,
106
+ status: agent.status ?? "unknown",
107
+ model: agent.model,
108
+ lastActivity: agent.lastSeen ?? now,
109
+ lastHeartbeat: now,
110
+ updatedAt: now,
111
+ }).catch((e) => console.warn(`[IngestEvents] snapshot upsert failed for ${snapshotId}: ${e.message}`));
112
+ }
113
+ // Insert event feed entries (skip duplicates)
114
+ let inserted = 0;
115
+ for (const ev of events) {
116
+ if (!ev.id || !ev.kind)
117
+ continue;
118
+ const feedId = `${officeId}:${ev.id}`;
119
+ const existing = await databases.flair.ObsEventFeed.get(feedId).catch(() => null);
120
+ if (existing)
121
+ continue;
122
+ await databases.flair.ObsEventFeed.put({
123
+ id: feedId,
124
+ officeId,
125
+ kind: ev.kind,
126
+ authorId: ev.authorId,
127
+ summary: ev.summary,
128
+ refId: ev.refId,
129
+ scope: ev.scope,
130
+ createdAt: ev.createdAt,
131
+ receivedAt: now,
132
+ expiresAt,
133
+ }).catch((e) => console.warn(`[IngestEvents] event insert failed for ${feedId}: ${e.message}`));
134
+ inserted++;
135
+ }
136
+ // Update office lastSeen + agentCount
137
+ await databases.flair.ObsOffice.put({
138
+ ...office,
139
+ status: "online",
140
+ lastSeen: now,
141
+ agentCount: agents.length,
142
+ updatedAt: now,
143
+ }).catch(() => { });
144
+ return new Response(JSON.stringify({ ok: true, events: inserted, agents: agents.length }), {
145
+ status: 200,
146
+ headers: { "Content-Type": "application/json" },
147
+ });
148
+ }
149
+ }
@@ -0,0 +1,13 @@
1
+ import { databases } from "@harperfast/harper";
2
+ export class Integration extends databases.flair.Integration {
3
+ async post(content, context) {
4
+ // S31-A: API never accepts plaintext credentials.
5
+ if (typeof content?.credential === "string" || typeof content?.token === "string") {
6
+ return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
7
+ status: 400,
8
+ headers: { "Content-Type": "application/json" },
9
+ });
10
+ }
11
+ return super.post(content, context);
12
+ }
13
+ }
@@ -0,0 +1,19 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ export class IssueTokens extends Resource {
3
+ static loadAsInstance = false;
4
+ async get(_target) {
5
+ const { refresh_token: refreshToken, operation_token: jwt } = await databases.system.hdb_user.operation({ operation: "create_authentication_tokens" }, this.getContext());
6
+ return { refreshToken, jwt };
7
+ }
8
+ async post(_target, data) {
9
+ if (!data?.username || !data?.password) {
10
+ throw new Error("username and password are required");
11
+ }
12
+ const { refresh_token: refreshToken, operation_token: jwt } = await databases.system.hdb_user.operation({
13
+ operation: "create_authentication_tokens",
14
+ username: data.username,
15
+ password: data.password,
16
+ });
17
+ return { refreshToken, jwt };
18
+ }
19
+ }
@@ -0,0 +1,122 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { patchRecord } from "./table-helpers.js";
3
+ import { isAdmin } from "./auth-middleware.js";
4
+ export class Memory extends databases.flair.Memory {
5
+ /**
6
+ * Override search() to scope collection GETs by authenticated agent.
7
+ *
8
+ * Security Critical: the agentId condition is wrapped as the outermost
9
+ * `and` block so user-supplied query operators cannot bypass it via
10
+ * boolean injection (e.g. [..., "or", { wildcard }]).
11
+ *
12
+ * Admin agents and unauthenticated internal calls pass through unfiltered.
13
+ * Non-admin calls also check MemoryGrant to include granted memories.
14
+ */
15
+ async search(query) {
16
+ // Access request context via Harper's Resource instance context
17
+ const ctx = this.getContext?.();
18
+ const request = ctx?.request ?? ctx;
19
+ const authAgent = request?.tpsAgent;
20
+ const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
21
+ // No auth context (internal admin call) or admin agent — unfiltered
22
+ if (!authAgent || isAdminAgent) {
23
+ return super.search(query);
24
+ }
25
+ // Collect agentIds this agent may read: own + any granted owners
26
+ const allowedOwners = [authAgent];
27
+ try {
28
+ for await (const grant of databases.flair.MemoryGrant.search({
29
+ conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
30
+ })) {
31
+ if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
32
+ allowedOwners.push(grant.ownerId);
33
+ }
34
+ }
35
+ }
36
+ catch { /* MemoryGrant table not yet populated — ignore */ }
37
+ // Build the agentId scope condition
38
+ const agentIdCondition = allowedOwners.length === 1
39
+ ? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
40
+ : { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
41
+ // Harper passes `query` as a RequestTarget (extends URLSearchParams) with pathname, id, etc.
42
+ // Table.search() reads `target.conditions` from it. We inject our scope condition there.
43
+ if (query && typeof query === "object" && !Array.isArray(query)) {
44
+ const existing = query.conditions ?? [];
45
+ query.conditions = Array.isArray(existing)
46
+ ? [agentIdCondition, ...existing]
47
+ : [agentIdCondition, existing];
48
+ return super.search(query);
49
+ }
50
+ // Fallback: plain array or no query (internal calls)
51
+ const conditions = Array.isArray(query) && query.length > 0
52
+ ? [agentIdCondition, ...query]
53
+ : [agentIdCondition];
54
+ return super.search(conditions);
55
+ }
56
+ async post(content, context) {
57
+ content.durability ||= "standard";
58
+ content.createdAt = new Date().toISOString();
59
+ content.updatedAt = content.createdAt;
60
+ content.archived = content.archived ?? false;
61
+ // Validate derivedFrom source IDs exist (best-effort, non-blocking)
62
+ if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
63
+ const now = content.createdAt;
64
+ for (const sourceId of content.derivedFrom) {
65
+ try {
66
+ const src = await databases.flair.Memory.get(sourceId);
67
+ if (src) {
68
+ patchRecord(databases.flair.Memory, sourceId, { lastReflected: now }).catch(() => { });
69
+ }
70
+ }
71
+ catch { }
72
+ }
73
+ }
74
+ // supersedes: optional reference to the ID of the memory this one replaces
75
+ if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
76
+ return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
77
+ status: 400, headers: { "Content-Type": "application/json" },
78
+ });
79
+ }
80
+ if (content.durability === "ephemeral" && !content.expiresAt) {
81
+ const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
82
+ content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
83
+ }
84
+ return super.post(content);
85
+ }
86
+ async put(content) {
87
+ const now = new Date().toISOString();
88
+ content.updatedAt = now;
89
+ // If archiving, record who + when
90
+ if (content.archived === true && !content.archivedAt) {
91
+ content.archivedAt = now;
92
+ // archivedBy should be set by the caller (CLI stamps req.tpsAgent via query param)
93
+ }
94
+ // If approving promotion, record timestamp
95
+ if (content.promotionStatus === "approved" && !content.promotedAt) {
96
+ content.promotedAt = now;
97
+ }
98
+ // Upgrade to permanent when approved
99
+ if (content.promotionStatus === "approved") {
100
+ content.durability = "permanent";
101
+ }
102
+ return super.put(content);
103
+ }
104
+ async delete(id) {
105
+ const record = await this.get(id);
106
+ if (!record)
107
+ return super.delete(id);
108
+ if (record.durability === "permanent") {
109
+ // Middleware already guards this for non-admins, but belt-and-suspenders
110
+ const ctx = this.getContext?.();
111
+ const request = ctx?.request ?? ctx;
112
+ const actorId = request?.tpsAgent;
113
+ if (actorId && !(await isAdmin(actorId))) {
114
+ return new Response(JSON.stringify({ error: "permanent_memory_cannot_be_deleted_by_non_admin" }), {
115
+ status: 403,
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+ }
119
+ }
120
+ return super.delete(id);
121
+ }
122
+ }
@@ -0,0 +1,263 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { getEmbedding } from "./embeddings-provider.js";
3
+ /**
4
+ * POST /MemoryBootstrap
5
+ *
6
+ * One-call context builder for agent cold starts.
7
+ * Returns prioritized, token-budgeted context with:
8
+ * 1. Soul records (identity, role, preferences)
9
+ * 2. Permanent memories (safety rules, core principles)
10
+ * 3. Recent memories (last 24-48h standard/persistent)
11
+ * 4. Task-relevant memories (semantic search if currentTask provided)
12
+ *
13
+ * Request:
14
+ * { agentId, currentTask?, maxTokens?, includeSoul?, since? }
15
+ *
16
+ * Response:
17
+ * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
18
+ */
19
+ // Rough token estimate: ~4 chars per token for English text
20
+ function estimateTokens(text) {
21
+ return Math.ceil(text.length / 4);
22
+ }
23
+ function formatMemory(m, supersedes) {
24
+ const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
25
+ const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
26
+ const chain = m.supersedes ? " [supersedes earlier decision]" : "";
27
+ return `${tag} ${m.content}${date}${chain}`;
28
+ }
29
+ export class BootstrapMemories extends Resource {
30
+ async post(data, _context) {
31
+ const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since, } = data || {};
32
+ if (!agentId) {
33
+ return new Response(JSON.stringify({ error: "agentId required" }), {
34
+ status: 400,
35
+ headers: { "Content-Type": "application/json" },
36
+ });
37
+ }
38
+ // Defense-in-depth: agentId must match authenticated agent
39
+ const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
40
+ const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
41
+ if (authenticatedAgent && !callerIsAdmin && agentId !== authenticatedAgent) {
42
+ return new Response(JSON.stringify({
43
+ error: "forbidden: agentId must match authenticated agent",
44
+ }), { status: 403, headers: { "Content-Type": "application/json" } });
45
+ }
46
+ const sections = {
47
+ soul: [],
48
+ skills: [],
49
+ permanent: [],
50
+ recent: [],
51
+ relevant: [],
52
+ events: [],
53
+ };
54
+ let tokenBudget = maxTokens;
55
+ let memoriesIncluded = 0;
56
+ let memoriesAvailable = 0;
57
+ // --- 1. Soul records (unconditional — not subject to token budget) ---
58
+ // Soul is who you are. It's not optional context to be trimmed.
59
+ // Skill assignments (key='skill-assignment') are separated into their own section.
60
+ const skillAssignments = [];
61
+ if (includeSoul) {
62
+ let soulTokens = 0;
63
+ for await (const record of databases.flair.Soul.search()) {
64
+ if (record.agentId !== agentId)
65
+ continue;
66
+ if (record.key === "skill-assignment") {
67
+ skillAssignments.push(record);
68
+ continue;
69
+ }
70
+ const line = `**${record.key}:** ${record.value}`;
71
+ sections.soul.push(line);
72
+ soulTokens += estimateTokens(line);
73
+ }
74
+ // Soul tokens are tracked but don't reduce memory budget
75
+ tokenBudget = maxTokens; // memory budget is separate from soul
76
+ }
77
+ // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
78
+ if (skillAssignments.length > 0) {
79
+ const priorityOrder = { critical: 0, high: 1, standard: 2, low: 3 };
80
+ skillAssignments.sort((a, b) => {
81
+ const pa = priorityOrder[a.priority ?? "standard"] ?? 2;
82
+ const pb = priorityOrder[b.priority ?? "standard"] ?? 2;
83
+ return pa - pb;
84
+ });
85
+ // Detect conflicts at same priority level
86
+ const byPriority = new Map();
87
+ for (const skill of skillAssignments) {
88
+ const p = skill.priority ?? "standard";
89
+ if (!byPriority.has(p))
90
+ byPriority.set(p, []);
91
+ byPriority.get(p).push(skill);
92
+ }
93
+ for (const skill of skillAssignments) {
94
+ const p = skill.priority ?? "standard";
95
+ let meta = {};
96
+ try {
97
+ meta = typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : (skill.metadata ?? {});
98
+ }
99
+ catch { }
100
+ const source = meta.source ? `, source: ${meta.source}` : "";
101
+ let line = `- ${skill.value} (${p} priority${source})`;
102
+ // Flag conflicts at same priority level
103
+ const peers = byPriority.get(p) ?? [];
104
+ if (peers.length > 1) {
105
+ line += " [SKILL_CONFLICT]";
106
+ }
107
+ sections.skills.push(line);
108
+ }
109
+ }
110
+ // --- 2. Permanent memories (always included, highest priority) ---
111
+ const allMemories = [];
112
+ for await (const record of databases.flair.Memory.search()) {
113
+ if (record.agentId !== agentId)
114
+ continue;
115
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
116
+ continue;
117
+ allMemories.push(record);
118
+ }
119
+ memoriesAvailable = allMemories.length;
120
+ // Build superseded set: exclude memories that have been replaced by newer ones
121
+ const supersededIds = new Set();
122
+ for (const m of allMemories) {
123
+ if (m.supersedes)
124
+ supersededIds.add(m.supersedes);
125
+ }
126
+ const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
127
+ const permanent = activeMemories.filter((m) => m.durability === "permanent");
128
+ for (const m of permanent) {
129
+ const line = formatMemory(m);
130
+ const cost = estimateTokens(line);
131
+ if (cost <= tokenBudget) {
132
+ sections.permanent.push(line);
133
+ tokenBudget -= cost;
134
+ memoriesIncluded++;
135
+ }
136
+ }
137
+ // --- 3. Recent memories (last 24-48h, standard + persistent) ---
138
+ const sinceDate = since
139
+ ? new Date(since)
140
+ : new Date(Date.now() - 48 * 3600_000);
141
+ const recent = activeMemories
142
+ .filter((m) => m.durability !== "permanent" &&
143
+ m.createdAt &&
144
+ new Date(m.createdAt) >= sinceDate)
145
+ .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
146
+ // Budget: up to 40% of remaining for recent
147
+ const recentBudget = Math.floor(tokenBudget * 0.4);
148
+ let recentSpent = 0;
149
+ for (const m of recent) {
150
+ const line = formatMemory(m);
151
+ const cost = estimateTokens(line);
152
+ if (recentSpent + cost > recentBudget)
153
+ continue;
154
+ sections.recent.push(line);
155
+ recentSpent += cost;
156
+ tokenBudget -= cost;
157
+ memoriesIncluded++;
158
+ }
159
+ // --- 4. Task-relevant memories (semantic search) ---
160
+ if (currentTask && tokenBudget > 200) {
161
+ let queryEmbedding = null;
162
+ try {
163
+ queryEmbedding = await getEmbedding(currentTask);
164
+ }
165
+ catch { }
166
+ if (queryEmbedding) {
167
+ // Score all non-included memories by relevance
168
+ const includedIds = new Set([
169
+ ...permanent.map((m) => m.id),
170
+ ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
171
+ ]);
172
+ const scored = allMemories
173
+ .filter((m) => !includedIds.has(m.id) && !supersededIds.has(m.id) && m.embedding?.length > 100)
174
+ .map((m) => {
175
+ let dot = 0;
176
+ const len = Math.min(queryEmbedding.length, m.embedding.length);
177
+ for (let i = 0; i < len; i++)
178
+ dot += queryEmbedding[i] * m.embedding[i];
179
+ return { memory: m, score: dot };
180
+ })
181
+ .filter((s) => s.score > 0.3)
182
+ .sort((a, b) => b.score - a.score);
183
+ for (const { memory: m } of scored) {
184
+ const line = formatMemory(m);
185
+ const cost = estimateTokens(line);
186
+ if (cost > tokenBudget)
187
+ continue;
188
+ sections.relevant.push(line);
189
+ tokenBudget -= cost;
190
+ memoriesIncluded++;
191
+ }
192
+ }
193
+ }
194
+ // --- 5. Recent OrgEvents for this agent ---
195
+ try {
196
+ const eventSince = data?.lastBootAt
197
+ ? new Date(data.lastBootAt)
198
+ : new Date(Date.now() - 24 * 3600_000);
199
+ const eventSinceStr = eventSince.toISOString();
200
+ const eventResults = [];
201
+ for await (const event of databases.flair.OrgEvent.search()) {
202
+ if (!event.createdAt || event.createdAt < eventSinceStr)
203
+ continue;
204
+ if (event.expiresAt && new Date(event.expiresAt) < new Date())
205
+ continue;
206
+ const targets = event.targetIds;
207
+ const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
208
+ if (!isRelevant)
209
+ continue;
210
+ eventResults.push(event);
211
+ }
212
+ eventResults.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
213
+ for (const evt of eventResults.slice(0, 10)) {
214
+ const elapsed = Date.now() - new Date(evt.createdAt).getTime();
215
+ const mins = Math.floor(elapsed / 60_000);
216
+ const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
217
+ sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
218
+ }
219
+ }
220
+ catch {
221
+ // non-fatal: OrgEvent table may not exist yet
222
+ }
223
+ // --- Build context string ---
224
+ const parts = [];
225
+ if (sections.soul.length > 0) {
226
+ parts.push("## Identity\n" + sections.soul.join("\n"));
227
+ }
228
+ if (sections.skills.length > 0) {
229
+ parts.push("## Active Skills\n" + sections.skills.join("\n"));
230
+ }
231
+ if (sections.permanent.length > 0) {
232
+ parts.push("## Core Principles\n" + sections.permanent.join("\n"));
233
+ }
234
+ if (sections.recent.length > 0) {
235
+ parts.push("## Recent Context\n" + sections.recent.join("\n"));
236
+ }
237
+ if (sections.relevant.length > 0) {
238
+ parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
239
+ }
240
+ if (sections.events.length > 0) {
241
+ parts.push("## Recent Org Events\n" + sections.events.join("\n"));
242
+ }
243
+ const context = parts.join("\n\n");
244
+ const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
245
+ const memoryTokens = maxTokens - tokenBudget;
246
+ return {
247
+ context,
248
+ sections: {
249
+ soul: sections.soul.length,
250
+ skills: sections.skills.length,
251
+ permanent: sections.permanent.length,
252
+ recent: sections.recent.length,
253
+ relevant: sections.relevant.length,
254
+ events: sections.events.length,
255
+ },
256
+ tokenEstimate: soulTokens + memoryTokens,
257
+ soulTokens,
258
+ memoryTokens,
259
+ memoriesIncluded,
260
+ memoriesAvailable,
261
+ };
262
+ }
263
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * POST /MemoryConsolidate
3
+ *
4
+ * Reviews an agent's persistent memories and returns candidates for
5
+ * promotion (standard→persistent or persistent→permanent proposal) or
6
+ * archival, based on retrieval count and age.
7
+ *
8
+ * Request:
9
+ * agentId string — which agent
10
+ * scope string — "persistent" | "standard" | "all" (default: "persistent")
11
+ * olderThan string? — duration like "30d", "7d" (default: "30d")
12
+ * limit number? — max candidates (default: 20)
13
+ *
14
+ * Response:
15
+ * candidates Array<{ memory, suggestion, reason }>
16
+ * prompt string
17
+ */
18
+ import { Resource, databases } from "@harperfast/harper";
19
+ import { isAdmin } from "./auth-middleware.js";
20
+ function parseDuration(s) {
21
+ const m = s.match(/^(\d+)([dhm])$/);
22
+ if (!m)
23
+ return 30 * 86400_000;
24
+ const n = Number(m[1]);
25
+ if (m[2] === "d")
26
+ return n * 86400_000;
27
+ if (m[2] === "h")
28
+ return n * 3600_000;
29
+ if (m[2] === "m")
30
+ return n * 60_000;
31
+ return 30 * 86400_000;
32
+ }
33
+ function evaluate(record, now, olderThanMs) {
34
+ const ageMs = record.createdAt ? now - new Date(record.createdAt).getTime() : 0;
35
+ const count = record.retrievalCount ?? 0;
36
+ const daysSinceRetrieved = record.lastRetrieved
37
+ ? (now - new Date(record.lastRetrieved).getTime()) / 86400_000
38
+ : Infinity;
39
+ const { embedding, ...memory } = record;
40
+ // Promote: high retrieval + persistent durability
41
+ if (record.durability === "persistent" && count >= 5) {
42
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times — strong promotion candidate for permanent` };
43
+ }
44
+ // Promote: standard → persistent if retrieved frequently
45
+ if (record.durability === "standard" && count >= 3 && ageMs > 7 * 86400_000) {
46
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times over ${Math.round(ageMs / 86400_000)} days — worth persisting` };
47
+ }
48
+ // Archive: old + never retrieved
49
+ if (daysSinceRetrieved > 30 && count === 0 && ageMs > olderThanMs) {
50
+ return { memory, suggestion: "archive", reason: `Never retrieved, ${Math.round(ageMs / 86400_000)} days old` };
51
+ }
52
+ // Archive: last retrieved > 60 days
53
+ if (daysSinceRetrieved > 60 && count < 2) {
54
+ return { memory, suggestion: "archive", reason: `Not retrieved in ${Math.round(daysSinceRetrieved)} days (only ${count} total retrievals)` };
55
+ }
56
+ return { memory, suggestion: "keep", reason: `Retrieved ${count} times, ${Math.round(daysSinceRetrieved)} days since last retrieval` };
57
+ }
58
+ export class ConsolidateMemories extends Resource {
59
+ async post(data) {
60
+ const { agentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
61
+ if (!agentId)
62
+ return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
63
+ const actorId = this.request?.tpsAgent;
64
+ if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
65
+ return new Response(JSON.stringify({ error: "forbidden: can only consolidate own memories" }), { status: 403 });
66
+ }
67
+ const olderThanMs = parseDuration(olderThan);
68
+ const now = Date.now();
69
+ const candidates = [];
70
+ for await (const record of databases.flair.Memory.search()) {
71
+ if (record.agentId !== agentId)
72
+ continue;
73
+ if (record.archived)
74
+ continue;
75
+ if (record.durability === "permanent")
76
+ continue; // permanent can't be demoted
77
+ if (scope === "persistent" && record.durability !== "persistent")
78
+ continue;
79
+ if (scope === "standard" && record.durability !== "standard")
80
+ continue;
81
+ const candidate = evaluate(record, now, olderThanMs);
82
+ candidates.push(candidate);
83
+ if (candidates.length >= limit * 3)
84
+ break; // over-fetch to sort
85
+ }
86
+ // Sort: promote first, then archive, then keep
87
+ const order = { promote: 0, archive: 1, keep: 2 };
88
+ candidates.sort((a, b) => order[a.suggestion] - order[b.suggestion]);
89
+ const top = candidates.slice(0, limit);
90
+ const promoteCount = top.filter(c => c.suggestion === "promote").length;
91
+ const archiveCount = top.filter(c => c.suggestion === "archive").length;
92
+ const prompt = `# Memory Consolidation Review — ${agentId}
93
+ Scope: ${scope} | OlderThan: ${olderThan} | Candidates: ${top.length}
94
+
95
+ Summary: ${promoteCount} promote candidates, ${archiveCount} archive candidates.
96
+
97
+ For each candidate below:
98
+ - PROMOTE: Upgrade standard→persistent (self-approve) or propose persistent→permanent (needs human)
99
+ - ARCHIVE: Soft-delete (hidden from search, recoverable)
100
+ - KEEP: No action
101
+
102
+ Use: tps memory approve <id> | tps memory archive <id> | skip`;
103
+ return { candidates: top, prompt };
104
+ }
105
+ }