@tpsdev-ai/flair 0.3.17 → 0.3.19

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.
@@ -1,323 +0,0 @@
1
- import { Resource, databases } from "@harperfast/harper";
2
- import { getEmbedding } from "./embeddings-provider.js";
3
-
4
- /**
5
- * POST /MemoryBootstrap
6
- *
7
- * One-call context builder for agent cold starts.
8
- * Returns prioritized, token-budgeted context with:
9
- * 1. Soul records (identity, role, preferences)
10
- * 2. Permanent memories (safety rules, core principles)
11
- * 3. Recent memories (last 24-48h standard/persistent)
12
- * 4. Task-relevant memories (semantic search if currentTask provided)
13
- *
14
- * Request:
15
- * { agentId, currentTask?, maxTokens?, includeSoul?, since? }
16
- *
17
- * Response:
18
- * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
19
- */
20
-
21
- // Rough token estimate: ~4 chars per token for English text
22
- function estimateTokens(text: string): number {
23
- return Math.ceil(text.length / 4);
24
- }
25
-
26
- function formatMemory(m: any, supersedes?: boolean): string {
27
- const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
28
- const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
29
- const chain = m.supersedes ? " [supersedes earlier decision]" : "";
30
- return `${tag} ${m.content}${date}${chain}`;
31
- }
32
-
33
- export class BootstrapMemories extends Resource {
34
- async post(data: any, _context?: any) {
35
- const {
36
- agentId,
37
- currentTask,
38
- maxTokens = 4000,
39
- includeSoul = true,
40
- since,
41
- } = data || {};
42
-
43
- if (!agentId) {
44
- return new Response(JSON.stringify({ error: "agentId required" }), {
45
- status: 400,
46
- headers: { "Content-Type": "application/json" },
47
- });
48
- }
49
-
50
- // Defense-in-depth: agentId must match authenticated agent
51
- const authenticatedAgent: string | undefined = (this as any).request?.headers?.get?.("x-tps-agent");
52
- const callerIsAdmin: boolean = (this as any).request?.tpsAgentIsAdmin === true;
53
- if (authenticatedAgent && !callerIsAdmin && agentId !== authenticatedAgent) {
54
- return new Response(JSON.stringify({
55
- error: "forbidden: agentId must match authenticated agent",
56
- }), { status: 403, headers: { "Content-Type": "application/json" } });
57
- }
58
-
59
- const sections: Record<string, string[]> = {
60
- soul: [],
61
- skills: [],
62
- permanent: [],
63
- recent: [],
64
- relevant: [],
65
- events: [],
66
- };
67
- let tokenBudget = maxTokens;
68
- let memoriesIncluded = 0;
69
- let memoriesAvailable = 0;
70
-
71
- // --- 1. Soul records (budgeted — prioritized by key importance) ---
72
- // Soul is who you are, but we still need to respect token budgets.
73
- // Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
74
- // injected by the runtime via workspace context, so we prioritize
75
- // concise soul entries over full file dumps.
76
- const SOUL_KEY_PRIORITY: Record<string, number> = {
77
- role: 0, identity: 1, thinking: 2, communication_style: 3,
78
- team: 4, ownership: 5, infrastructure: 6, "user-context": 7,
79
- // Full workspace files — lowest priority (runtime already injects these)
80
- soul: 90, "workspace-rules": 91,
81
- };
82
-
83
- const skillAssignments: any[] = [];
84
- const soulMaxTokens = Math.floor(maxTokens * 0.4); // 40% of budget for soul
85
- if (includeSoul) {
86
- let soulTokens = 0;
87
- const soulEntries: { key: string; line: string; tokens: number; priority: number }[] = [];
88
-
89
- for await (const record of (databases as any).flair.Soul.search()) {
90
- if (record.agentId !== agentId) continue;
91
- if (record.key === "skill-assignment") {
92
- skillAssignments.push(record);
93
- continue;
94
- }
95
- const line = `**${record.key}:** ${record.value}`;
96
- const tokens = estimateTokens(line);
97
- const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
98
- soulEntries.push({ key: record.key, line, tokens, priority });
99
- }
100
-
101
- // Sort by priority (lower = more important)
102
- soulEntries.sort((a, b) => a.priority - b.priority);
103
-
104
- for (const entry of soulEntries) {
105
- if (soulTokens + entry.tokens > soulMaxTokens) {
106
- // Skip large entries that exceed budget — truncate or skip
107
- if (entry.priority >= 90) continue; // skip full workspace files
108
- // Truncate if it's important but too long
109
- const maxChars = (soulMaxTokens - soulTokens) * 4;
110
- if (maxChars > 100) {
111
- const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
112
- sections.soul.push(truncated);
113
- soulTokens += estimateTokens(truncated);
114
- }
115
- continue;
116
- }
117
- sections.soul.push(entry.line);
118
- soulTokens += entry.tokens;
119
- }
120
- }
121
-
122
- // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
123
- if (skillAssignments.length > 0) {
124
- const priorityOrder: Record<string, number> = { critical: 0, high: 1, standard: 2, low: 3 };
125
- skillAssignments.sort((a, b) => {
126
- const pa = priorityOrder[a.priority ?? "standard"] ?? 2;
127
- const pb = priorityOrder[b.priority ?? "standard"] ?? 2;
128
- return pa - pb;
129
- });
130
-
131
- // Detect conflicts at same priority level
132
- const byPriority = new Map<string, any[]>();
133
- for (const skill of skillAssignments) {
134
- const p = skill.priority ?? "standard";
135
- if (!byPriority.has(p)) byPriority.set(p, []);
136
- byPriority.get(p)!.push(skill);
137
- }
138
-
139
- for (const skill of skillAssignments) {
140
- const p = skill.priority ?? "standard";
141
- let meta: any = {};
142
- try { meta = typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : (skill.metadata ?? {}); } catch {}
143
- const source = meta.source ? `, source: ${meta.source}` : "";
144
- let line = `- ${skill.value} (${p} priority${source})`;
145
- // Flag conflicts at same priority level
146
- const peers = byPriority.get(p) ?? [];
147
- if (peers.length > 1) {
148
- line += " [SKILL_CONFLICT]";
149
- }
150
- sections.skills.push(line);
151
- }
152
- }
153
-
154
- // --- 2. Permanent memories (always included, highest priority) ---
155
- const allMemories: any[] = [];
156
- for await (const record of (databases as any).flair.Memory.search()) {
157
- if (record.agentId !== agentId) continue;
158
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) continue;
159
- allMemories.push(record);
160
- }
161
- memoriesAvailable = allMemories.length;
162
-
163
- // Build superseded set: exclude memories that have been replaced by newer ones
164
- const supersededIds = new Set<string>();
165
- for (const m of allMemories) {
166
- if (m.supersedes) supersededIds.add(m.supersedes);
167
- }
168
- const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
169
-
170
- const permanent = activeMemories.filter((m) => m.durability === "permanent");
171
- for (const m of permanent) {
172
- const line = formatMemory(m);
173
- const cost = estimateTokens(line);
174
- if (cost <= tokenBudget) {
175
- sections.permanent.push(line);
176
- tokenBudget -= cost;
177
- memoriesIncluded++;
178
- }
179
- }
180
-
181
- // --- 3. Recent memories (adaptive window) ---
182
- // Start with 48h. If nothing found, widen to 7d, then 30d.
183
- // This prevents empty recent sections for agents that were idle.
184
- const nonPermanent = activeMemories
185
- .filter((m) => m.durability !== "permanent" && m.createdAt)
186
- .sort((a: any, b: any) => (b.createdAt || "").localeCompare(a.createdAt || ""));
187
-
188
- let effectiveSince: Date;
189
- if (since) {
190
- effectiveSince = new Date(since);
191
- } else {
192
- const windows = [48 * 3600_000, 7 * 24 * 3600_000, 30 * 24 * 3600_000];
193
- effectiveSince = new Date(Date.now() - windows[0]);
194
- for (const w of windows) {
195
- effectiveSince = new Date(Date.now() - w);
196
- const count = nonPermanent.filter((m) => new Date(m.createdAt!) >= effectiveSince).length;
197
- if (count >= 3) break; // found enough recent memories
198
- }
199
- }
200
-
201
- const recent = nonPermanent.filter((m) => new Date(m.createdAt!) >= effectiveSince);
202
-
203
- // Budget: up to 40% of remaining for recent
204
- const recentBudget = Math.floor(tokenBudget * 0.4);
205
- let recentSpent = 0;
206
- for (const m of recent) {
207
- const line = formatMemory(m);
208
- const cost = estimateTokens(line);
209
- if (recentSpent + cost > recentBudget) continue;
210
- sections.recent.push(line);
211
- recentSpent += cost;
212
- tokenBudget -= cost;
213
- memoriesIncluded++;
214
- }
215
-
216
- // --- 4. Task-relevant memories (semantic search) ---
217
- if (currentTask && tokenBudget > 200) {
218
- let queryEmbedding: number[] | null = null;
219
- try {
220
- queryEmbedding = await getEmbedding(currentTask);
221
- } catch {}
222
-
223
- if (queryEmbedding) {
224
- // Score all non-included memories by relevance
225
- const includedIds = new Set([
226
- ...permanent.map((m) => m.id),
227
- ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
228
- ]);
229
-
230
- const scored = allMemories
231
- .filter((m) => !includedIds.has(m.id) && !supersededIds.has(m.id) && m.embedding?.length > 100)
232
- .map((m) => {
233
- let dot = 0;
234
- const len = Math.min(queryEmbedding!.length, m.embedding.length);
235
- for (let i = 0; i < len; i++) dot += queryEmbedding![i] * m.embedding[i];
236
- return { memory: m, score: dot };
237
- })
238
- .filter((s) => s.score > 0.3)
239
- .sort((a, b) => b.score - a.score);
240
-
241
- for (const { memory: m } of scored) {
242
- const line = formatMemory(m);
243
- const cost = estimateTokens(line);
244
- if (cost > tokenBudget) continue;
245
- sections.relevant.push(line);
246
- tokenBudget -= cost;
247
- memoriesIncluded++;
248
- }
249
- }
250
- }
251
-
252
- // --- 5. Recent OrgEvents for this agent ---
253
- try {
254
- const eventSince = data?.lastBootAt
255
- ? new Date(data.lastBootAt)
256
- : new Date(Date.now() - 24 * 3600_000);
257
- const eventSinceStr = eventSince.toISOString();
258
- const eventResults: any[] = [];
259
-
260
- for await (const event of (databases as any).flair.OrgEvent.search()) {
261
- if (!event.createdAt || event.createdAt < eventSinceStr) continue;
262
- if (event.expiresAt && new Date(event.expiresAt) < new Date()) continue;
263
- const targets = event.targetIds;
264
- const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
265
- if (!isRelevant) continue;
266
- eventResults.push(event);
267
- }
268
-
269
- eventResults.sort((a: any, b: any) => (a.createdAt || "").localeCompare(b.createdAt || ""));
270
- for (const evt of eventResults.slice(0, 10)) {
271
- const elapsed = Date.now() - new Date(evt.createdAt).getTime();
272
- const mins = Math.floor(elapsed / 60_000);
273
- const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
274
- sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
275
- }
276
- } catch {
277
- // non-fatal: OrgEvent table may not exist yet
278
- }
279
-
280
- // --- Build context string ---
281
- const parts: string[] = [];
282
-
283
- if (sections.soul.length > 0) {
284
- parts.push("## Identity\n" + sections.soul.join("\n"));
285
- }
286
- if (sections.skills.length > 0) {
287
- parts.push("## Active Skills\n" + sections.skills.join("\n"));
288
- }
289
- if (sections.permanent.length > 0) {
290
- parts.push("## Core Principles\n" + sections.permanent.join("\n"));
291
- }
292
- if (sections.recent.length > 0) {
293
- parts.push("## Recent Context\n" + sections.recent.join("\n"));
294
- }
295
- if (sections.relevant.length > 0) {
296
- parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
297
- }
298
- if (sections.events.length > 0) {
299
- parts.push("## Recent Org Events\n" + sections.events.join("\n"));
300
- }
301
-
302
- const context = parts.join("\n\n");
303
- const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
304
- const memoryTokens = maxTokens - tokenBudget;
305
-
306
- return {
307
- context,
308
- sections: {
309
- soul: sections.soul.length,
310
- skills: sections.skills.length,
311
- permanent: sections.permanent.length,
312
- recent: sections.recent.length,
313
- relevant: sections.relevant.length,
314
- events: sections.events.length,
315
- },
316
- tokenEstimate: soulTokens + memoryTokens,
317
- soulTokens,
318
- memoryTokens,
319
- memoriesIncluded,
320
- memoriesAvailable,
321
- };
322
- }
323
- }
@@ -1,121 +0,0 @@
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
-
19
- import { Resource, databases } from "@harperfast/harper";
20
- import { isAdmin } from "./auth-middleware.js";
21
-
22
- function parseDuration(s: string): number {
23
- const m = s.match(/^(\d+)([dhm])$/);
24
- if (!m) return 30 * 86400_000;
25
- const n = Number(m[1]);
26
- if (m[2] === "d") return n * 86400_000;
27
- if (m[2] === "h") return n * 3600_000;
28
- if (m[2] === "m") return n * 60_000;
29
- return 30 * 86400_000;
30
- }
31
-
32
- type Suggestion = "promote" | "archive" | "keep";
33
-
34
- interface Candidate {
35
- memory: Record<string, unknown>;
36
- suggestion: Suggestion;
37
- reason: string;
38
- }
39
-
40
- function evaluate(record: any, now: number, olderThanMs: number): Candidate {
41
- const ageMs = record.createdAt ? now - new Date(record.createdAt).getTime() : 0;
42
- const count = record.retrievalCount ?? 0;
43
- const daysSinceRetrieved = record.lastRetrieved
44
- ? (now - new Date(record.lastRetrieved).getTime()) / 86400_000
45
- : Infinity;
46
- const { embedding, ...memory } = record;
47
-
48
- // Promote: high retrieval + persistent durability
49
- if (record.durability === "persistent" && count >= 5) {
50
- return { memory, suggestion: "promote", reason: `Retrieved ${count} times — strong promotion candidate for permanent` };
51
- }
52
-
53
- // Promote: standard → persistent if retrieved frequently
54
- if (record.durability === "standard" && count >= 3 && ageMs > 7 * 86400_000) {
55
- return { memory, suggestion: "promote", reason: `Retrieved ${count} times over ${Math.round(ageMs / 86400_000)} days — worth persisting` };
56
- }
57
-
58
- // Archive: old + never retrieved
59
- if (daysSinceRetrieved > 30 && count === 0 && ageMs > olderThanMs) {
60
- return { memory, suggestion: "archive", reason: `Never retrieved, ${Math.round(ageMs / 86400_000)} days old` };
61
- }
62
-
63
- // Archive: last retrieved > 60 days
64
- if (daysSinceRetrieved > 60 && count < 2) {
65
- return { memory, suggestion: "archive", reason: `Not retrieved in ${Math.round(daysSinceRetrieved)} days (only ${count} total retrievals)` };
66
- }
67
-
68
- return { memory, suggestion: "keep", reason: `Retrieved ${count} times, ${Math.round(daysSinceRetrieved)} days since last retrieval` };
69
- }
70
-
71
- export class ConsolidateMemories extends Resource {
72
- async post(data: any) {
73
- const { agentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
74
-
75
- if (!agentId) return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
76
-
77
- const actorId = (this as any).request?.tpsAgent;
78
- if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
79
- return new Response(JSON.stringify({ error: "forbidden: can only consolidate own memories" }), { status: 403 });
80
- }
81
-
82
- const olderThanMs = parseDuration(olderThan);
83
- const now = Date.now();
84
- const candidates: Candidate[] = [];
85
-
86
- for await (const record of (databases as any).flair.Memory.search()) {
87
- if (record.agentId !== agentId) continue;
88
- if (record.archived) continue;
89
- if (record.durability === "permanent") continue; // permanent can't be demoted
90
-
91
- if (scope === "persistent" && record.durability !== "persistent") continue;
92
- if (scope === "standard" && record.durability !== "standard") continue;
93
-
94
- const candidate = evaluate(record, now, olderThanMs);
95
- candidates.push(candidate);
96
- if (candidates.length >= limit * 3) break; // over-fetch to sort
97
- }
98
-
99
- // Sort: promote first, then archive, then keep
100
- const order: Record<Suggestion, number> = { promote: 0, archive: 1, keep: 2 };
101
- candidates.sort((a, b) => order[a.suggestion] - order[b.suggestion]);
102
- const top = candidates.slice(0, limit);
103
-
104
- const promoteCount = top.filter(c => c.suggestion === "promote").length;
105
- const archiveCount = top.filter(c => c.suggestion === "archive").length;
106
-
107
- const prompt = `# Memory Consolidation Review — ${agentId}
108
- Scope: ${scope} | OlderThan: ${olderThan} | Candidates: ${top.length}
109
-
110
- Summary: ${promoteCount} promote candidates, ${archiveCount} archive candidates.
111
-
112
- For each candidate below:
113
- - PROMOTE: Upgrade standard→persistent (self-approve) or propose persistent→permanent (needs human)
114
- - ARCHIVE: Soft-delete (hidden from search, recoverable)
115
- - KEEP: No action
116
-
117
- Use: tps memory approve <id> | tps memory archive <id> | skip`;
118
-
119
- return { candidates: top, prompt };
120
- }
121
- }
@@ -1,48 +0,0 @@
1
- import { Resource, databases } from "@harperfast/harper";
2
- import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
3
-
4
- export class FeedMemories extends Resource {
5
- async post(content: any) {
6
- const agentId = String(content?.agentId ?? "");
7
- const body = String(content?.content ?? "");
8
- if (!agentId || !body) {
9
- return new Response(JSON.stringify({ error: "agentId and content are required" }), {
10
- status: 400,
11
- headers: { "Content-Type": "application/json" },
12
- });
13
- }
14
-
15
- const now = new Date().toISOString();
16
- const contentHash = computeContentHash(agentId, body);
17
-
18
- const existing = await findExistingMemoryByContentHash((databases as any).flair.Memory.search(), agentId, contentHash);
19
- if (existing) return existing;
20
-
21
- const record = {
22
- ...content,
23
- id: content.id ?? `${agentId}-${Date.now()}`,
24
- agentId,
25
- content: body,
26
- contentHash,
27
- durability: content.durability ?? "standard",
28
- createdAt: content.createdAt ?? now,
29
- updatedAt: content.updatedAt ?? now,
30
- archived: content.archived ?? false,
31
- };
32
-
33
- await (databases as any).flair.Memory.put(record);
34
- return record;
35
- }
36
-
37
- async *connect(target: any, incomingMessages: any) {
38
- const subscription = await (databases as any).flair.Memory.subscribe(target);
39
-
40
- if (!incomingMessages) {
41
- return subscription;
42
- }
43
-
44
- for await (const event of subscription) {
45
- yield event;
46
- }
47
- }
48
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * MemoryMaintenance.ts — Maintenance worker for memory hygiene.
3
- *
4
- * POST /MemoryMaintenance/ — runs cleanup tasks:
5
- * 1. Delete expired ephemeral memories (expiresAt < now)
6
- * 2. Archive old session memories (> 30 days, standard durability)
7
- * 3. Report stats
8
- *
9
- * Designed to run periodically (daily cron or heartbeat).
10
- * Requires admin auth.
11
- */
12
-
13
- export default class MemoryMaintenance {
14
- static ROUTE = "MemoryMaintenance";
15
- static METHOD = "POST";
16
-
17
- async post(data: any) {
18
- const { databases }: any = this;
19
- const request = (this as any).request;
20
- const { dryRun = false, agentId } = data || {};
21
-
22
- // Scope to authenticated agent. Admin can pass agentId for system-wide
23
- // maintenance; non-admin always scoped to their own agent.
24
- const authAgent = request?.headers?.get?.("x-tps-agent");
25
- const isAdmin = (request as any)?.tpsAgentIsAdmin === true;
26
- const targetAgent = isAdmin && agentId ? agentId : authAgent;
27
-
28
- if (!targetAgent && !isAdmin) {
29
- return { error: "agentId required" };
30
- }
31
-
32
- const now = new Date();
33
- const stats = { expired: 0, archived: 0, total: 0, errors: 0, agent: targetAgent || "all" };
34
-
35
- try {
36
- for await (const record of (databases as any).flair.Memory.search()) {
37
- // Skip records not belonging to target agent (unless admin running system-wide)
38
- if (targetAgent && record.agentId !== targetAgent) continue;
39
- stats.total++;
40
-
41
- // 1. Delete expired memories
42
- if (record.expiresAt && new Date(record.expiresAt) < now) {
43
- if (!dryRun) {
44
- try {
45
- await (databases as any).flair.Memory.delete(record.id);
46
- stats.expired++;
47
- } catch {
48
- stats.errors++;
49
- }
50
- } else {
51
- stats.expired++;
52
- }
53
- continue;
54
- }
55
-
56
- // 2. Archive old standard session memories (> 30 days)
57
- // These are low-value session notes that weren't promoted to persistent.
58
- // Archiving removes them from search results but keeps the data.
59
- if (
60
- record.durability === "standard" &&
61
- record.type === "session" &&
62
- !record.archived &&
63
- record.createdAt
64
- ) {
65
- const ageMs = now.getTime() - new Date(record.createdAt).getTime();
66
- const ageDays = ageMs / (24 * 3600_000);
67
- if (ageDays > 30) {
68
- if (!dryRun) {
69
- try {
70
- // Soft archive — set archived flag, keep data
71
- await (databases as any).flair.Memory.update(record.id, {
72
- ...record,
73
- archived: true,
74
- archivedAt: now.toISOString(),
75
- });
76
- stats.archived++;
77
- } catch {
78
- stats.errors++;
79
- }
80
- } else {
81
- stats.archived++;
82
- }
83
- }
84
- }
85
- }
86
- } catch (err: any) {
87
- return { error: err.message, stats };
88
- }
89
-
90
- return {
91
- message: dryRun ? "Dry run complete" : "Maintenance complete",
92
- stats,
93
- };
94
- }
95
- }