@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.
- package/LICENSE +19 -0
- package/README.md +246 -0
- package/SECURITY.md +116 -0
- package/config.yaml +16 -0
- package/dist/resources/A2AAdapter.js +474 -0
- package/dist/resources/Agent.js +9 -0
- package/dist/resources/AgentCard.js +45 -0
- package/dist/resources/AgentSeed.js +111 -0
- package/dist/resources/IngestEvents.js +149 -0
- package/dist/resources/Integration.js +13 -0
- package/dist/resources/IssueTokens.js +19 -0
- package/dist/resources/Memory.js +122 -0
- package/dist/resources/MemoryBootstrap.js +263 -0
- package/dist/resources/MemoryConsolidate.js +105 -0
- package/dist/resources/MemoryFeed.js +41 -0
- package/dist/resources/MemoryReflect.js +105 -0
- package/dist/resources/OrgEvent.js +43 -0
- package/dist/resources/OrgEventCatchup.js +65 -0
- package/dist/resources/OrgEventMaintenance.js +29 -0
- package/dist/resources/SemanticSearch.js +147 -0
- package/dist/resources/SkillScan.js +101 -0
- package/dist/resources/Soul.js +9 -0
- package/dist/resources/SoulFeed.js +12 -0
- package/dist/resources/WorkspaceLatest.js +45 -0
- package/dist/resources/WorkspaceState.js +76 -0
- package/dist/resources/auth-middleware.js +470 -0
- package/dist/resources/embeddings-provider.js +127 -0
- package/dist/resources/embeddings.js +42 -0
- package/dist/resources/health.js +6 -0
- package/dist/resources/memory-feed-lib.js +15 -0
- package/dist/resources/table-helpers.js +35 -0
- package/package.json +62 -0
- package/resources/A2AAdapter.ts +510 -0
- package/resources/Agent.ts +10 -0
- package/resources/AgentCard.ts +65 -0
- package/resources/AgentSeed.ts +119 -0
- package/resources/IngestEvents.ts +189 -0
- package/resources/Integration.ts +14 -0
- package/resources/IssueTokens.ts +29 -0
- package/resources/Memory.ts +138 -0
- package/resources/MemoryBootstrap.ts +283 -0
- package/resources/MemoryConsolidate.ts +121 -0
- package/resources/MemoryFeed.ts +48 -0
- package/resources/MemoryReflect.ts +122 -0
- package/resources/OrgEvent.ts +63 -0
- package/resources/OrgEventCatchup.ts +89 -0
- package/resources/OrgEventMaintenance.ts +37 -0
- package/resources/SemanticSearch.ts +157 -0
- package/resources/SkillScan.ts +146 -0
- package/resources/Soul.ts +10 -0
- package/resources/SoulFeed.ts +15 -0
- package/resources/WorkspaceLatest.ts +66 -0
- package/resources/WorkspaceState.ts +102 -0
- package/resources/auth-middleware.ts +502 -0
- package/resources/embeddings-provider.ts +144 -0
- package/resources/embeddings.ts +28 -0
- package/resources/health.ts +7 -0
- package/resources/memory-feed-lib.ts +22 -0
- package/resources/table-helpers.ts +46 -0
- package/schemas/agent.graphql +22 -0
- package/schemas/event.graphql +12 -0
- package/schemas/memory.graphql +50 -0
- package/schemas/schema.graphql +41 -0
- package/schemas/workspace.graphql +14 -0
|
@@ -0,0 +1,283 @@
|
|
|
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 (unconditional — not subject to token budget) ---
|
|
72
|
+
// Soul is who you are. It's not optional context to be trimmed.
|
|
73
|
+
// Skill assignments (key='skill-assignment') are separated into their own section.
|
|
74
|
+
const skillAssignments: any[] = [];
|
|
75
|
+
if (includeSoul) {
|
|
76
|
+
let soulTokens = 0;
|
|
77
|
+
for await (const record of (databases as any).flair.Soul.search()) {
|
|
78
|
+
if (record.agentId !== agentId) continue;
|
|
79
|
+
if (record.key === "skill-assignment") {
|
|
80
|
+
skillAssignments.push(record);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const line = `**${record.key}:** ${record.value}`;
|
|
84
|
+
sections.soul.push(line);
|
|
85
|
+
soulTokens += estimateTokens(line);
|
|
86
|
+
}
|
|
87
|
+
// Soul tokens are tracked but don't reduce memory budget
|
|
88
|
+
tokenBudget = maxTokens; // memory budget is separate from soul
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- 1b. Skill assignments (ordered by priority, conflict detection) ---
|
|
92
|
+
if (skillAssignments.length > 0) {
|
|
93
|
+
const priorityOrder: Record<string, number> = { critical: 0, high: 1, standard: 2, low: 3 };
|
|
94
|
+
skillAssignments.sort((a, b) => {
|
|
95
|
+
const pa = priorityOrder[a.priority ?? "standard"] ?? 2;
|
|
96
|
+
const pb = priorityOrder[b.priority ?? "standard"] ?? 2;
|
|
97
|
+
return pa - pb;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Detect conflicts at same priority level
|
|
101
|
+
const byPriority = new Map<string, any[]>();
|
|
102
|
+
for (const skill of skillAssignments) {
|
|
103
|
+
const p = skill.priority ?? "standard";
|
|
104
|
+
if (!byPriority.has(p)) byPriority.set(p, []);
|
|
105
|
+
byPriority.get(p)!.push(skill);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const skill of skillAssignments) {
|
|
109
|
+
const p = skill.priority ?? "standard";
|
|
110
|
+
let meta: any = {};
|
|
111
|
+
try { meta = typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : (skill.metadata ?? {}); } catch {}
|
|
112
|
+
const source = meta.source ? `, source: ${meta.source}` : "";
|
|
113
|
+
let line = `- ${skill.value} (${p} priority${source})`;
|
|
114
|
+
// Flag conflicts at same priority level
|
|
115
|
+
const peers = byPriority.get(p) ?? [];
|
|
116
|
+
if (peers.length > 1) {
|
|
117
|
+
line += " [SKILL_CONFLICT]";
|
|
118
|
+
}
|
|
119
|
+
sections.skills.push(line);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 2. Permanent memories (always included, highest priority) ---
|
|
124
|
+
const allMemories: any[] = [];
|
|
125
|
+
for await (const record of (databases as any).flair.Memory.search()) {
|
|
126
|
+
if (record.agentId !== agentId) continue;
|
|
127
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) continue;
|
|
128
|
+
allMemories.push(record);
|
|
129
|
+
}
|
|
130
|
+
memoriesAvailable = allMemories.length;
|
|
131
|
+
|
|
132
|
+
// Build superseded set: exclude memories that have been replaced by newer ones
|
|
133
|
+
const supersededIds = new Set<string>();
|
|
134
|
+
for (const m of allMemories) {
|
|
135
|
+
if (m.supersedes) supersededIds.add(m.supersedes);
|
|
136
|
+
}
|
|
137
|
+
const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
|
|
138
|
+
|
|
139
|
+
const permanent = activeMemories.filter((m) => m.durability === "permanent");
|
|
140
|
+
for (const m of permanent) {
|
|
141
|
+
const line = formatMemory(m);
|
|
142
|
+
const cost = estimateTokens(line);
|
|
143
|
+
if (cost <= tokenBudget) {
|
|
144
|
+
sections.permanent.push(line);
|
|
145
|
+
tokenBudget -= cost;
|
|
146
|
+
memoriesIncluded++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- 3. Recent memories (last 24-48h, standard + persistent) ---
|
|
151
|
+
const sinceDate = since
|
|
152
|
+
? new Date(since)
|
|
153
|
+
: new Date(Date.now() - 48 * 3600_000);
|
|
154
|
+
const recent = activeMemories
|
|
155
|
+
.filter(
|
|
156
|
+
(m) =>
|
|
157
|
+
m.durability !== "permanent" &&
|
|
158
|
+
m.createdAt &&
|
|
159
|
+
new Date(m.createdAt) >= sinceDate
|
|
160
|
+
)
|
|
161
|
+
.sort((a: any, b: any) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
162
|
+
|
|
163
|
+
// Budget: up to 40% of remaining for recent
|
|
164
|
+
const recentBudget = Math.floor(tokenBudget * 0.4);
|
|
165
|
+
let recentSpent = 0;
|
|
166
|
+
for (const m of recent) {
|
|
167
|
+
const line = formatMemory(m);
|
|
168
|
+
const cost = estimateTokens(line);
|
|
169
|
+
if (recentSpent + cost > recentBudget) continue;
|
|
170
|
+
sections.recent.push(line);
|
|
171
|
+
recentSpent += cost;
|
|
172
|
+
tokenBudget -= cost;
|
|
173
|
+
memoriesIncluded++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- 4. Task-relevant memories (semantic search) ---
|
|
177
|
+
if (currentTask && tokenBudget > 200) {
|
|
178
|
+
let queryEmbedding: number[] | null = null;
|
|
179
|
+
try {
|
|
180
|
+
queryEmbedding = await getEmbedding(currentTask);
|
|
181
|
+
} catch {}
|
|
182
|
+
|
|
183
|
+
if (queryEmbedding) {
|
|
184
|
+
// Score all non-included memories by relevance
|
|
185
|
+
const includedIds = new Set([
|
|
186
|
+
...permanent.map((m) => m.id),
|
|
187
|
+
...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const scored = allMemories
|
|
191
|
+
.filter((m) => !includedIds.has(m.id) && !supersededIds.has(m.id) && m.embedding?.length > 100)
|
|
192
|
+
.map((m) => {
|
|
193
|
+
let dot = 0;
|
|
194
|
+
const len = Math.min(queryEmbedding!.length, m.embedding.length);
|
|
195
|
+
for (let i = 0; i < len; i++) dot += queryEmbedding![i] * m.embedding[i];
|
|
196
|
+
return { memory: m, score: dot };
|
|
197
|
+
})
|
|
198
|
+
.filter((s) => s.score > 0.3)
|
|
199
|
+
.sort((a, b) => b.score - a.score);
|
|
200
|
+
|
|
201
|
+
for (const { memory: m } of scored) {
|
|
202
|
+
const line = formatMemory(m);
|
|
203
|
+
const cost = estimateTokens(line);
|
|
204
|
+
if (cost > tokenBudget) continue;
|
|
205
|
+
sections.relevant.push(line);
|
|
206
|
+
tokenBudget -= cost;
|
|
207
|
+
memoriesIncluded++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- 5. Recent OrgEvents for this agent ---
|
|
213
|
+
try {
|
|
214
|
+
const eventSince = data?.lastBootAt
|
|
215
|
+
? new Date(data.lastBootAt)
|
|
216
|
+
: new Date(Date.now() - 24 * 3600_000);
|
|
217
|
+
const eventSinceStr = eventSince.toISOString();
|
|
218
|
+
const eventResults: any[] = [];
|
|
219
|
+
|
|
220
|
+
for await (const event of (databases as any).flair.OrgEvent.search()) {
|
|
221
|
+
if (!event.createdAt || event.createdAt < eventSinceStr) continue;
|
|
222
|
+
if (event.expiresAt && new Date(event.expiresAt) < new Date()) continue;
|
|
223
|
+
const targets = event.targetIds;
|
|
224
|
+
const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
|
|
225
|
+
if (!isRelevant) continue;
|
|
226
|
+
eventResults.push(event);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
eventResults.sort((a: any, b: any) => (a.createdAt || "").localeCompare(b.createdAt || ""));
|
|
230
|
+
for (const evt of eventResults.slice(0, 10)) {
|
|
231
|
+
const elapsed = Date.now() - new Date(evt.createdAt).getTime();
|
|
232
|
+
const mins = Math.floor(elapsed / 60_000);
|
|
233
|
+
const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
|
|
234
|
+
sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// non-fatal: OrgEvent table may not exist yet
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Build context string ---
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
|
|
243
|
+
if (sections.soul.length > 0) {
|
|
244
|
+
parts.push("## Identity\n" + sections.soul.join("\n"));
|
|
245
|
+
}
|
|
246
|
+
if (sections.skills.length > 0) {
|
|
247
|
+
parts.push("## Active Skills\n" + sections.skills.join("\n"));
|
|
248
|
+
}
|
|
249
|
+
if (sections.permanent.length > 0) {
|
|
250
|
+
parts.push("## Core Principles\n" + sections.permanent.join("\n"));
|
|
251
|
+
}
|
|
252
|
+
if (sections.recent.length > 0) {
|
|
253
|
+
parts.push("## Recent Context\n" + sections.recent.join("\n"));
|
|
254
|
+
}
|
|
255
|
+
if (sections.relevant.length > 0) {
|
|
256
|
+
parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
|
|
257
|
+
}
|
|
258
|
+
if (sections.events.length > 0) {
|
|
259
|
+
parts.push("## Recent Org Events\n" + sections.events.join("\n"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const context = parts.join("\n\n");
|
|
263
|
+
const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
|
|
264
|
+
const memoryTokens = maxTokens - tokenBudget;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
context,
|
|
268
|
+
sections: {
|
|
269
|
+
soul: sections.soul.length,
|
|
270
|
+
skills: sections.skills.length,
|
|
271
|
+
permanent: sections.permanent.length,
|
|
272
|
+
recent: sections.recent.length,
|
|
273
|
+
relevant: sections.relevant.length,
|
|
274
|
+
events: sections.events.length,
|
|
275
|
+
},
|
|
276
|
+
tokenEstimate: soulTokens + memoryTokens,
|
|
277
|
+
soulTokens,
|
|
278
|
+
memoryTokens,
|
|
279
|
+
memoriesIncluded,
|
|
280
|
+
memoriesAvailable,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /MemoryReflect
|
|
3
|
+
*
|
|
4
|
+
* Gathers recent memories for an agent and returns a structured reflection
|
|
5
|
+
* prompt. The agent feeds the prompt + memories to its LLM and writes
|
|
6
|
+
* insights back as persistent memories (with derivedFrom linking).
|
|
7
|
+
*
|
|
8
|
+
* Request:
|
|
9
|
+
* agentId string — which agent to reflect on
|
|
10
|
+
* scope string — "recent" | "tagged" | "all" (default: "recent")
|
|
11
|
+
* since string? — ISO timestamp lower bound (default: 24h ago)
|
|
12
|
+
* maxMemories number? — cap (default: 50)
|
|
13
|
+
* focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" (default: "lessons_learned")
|
|
14
|
+
* tag string? — required when scope="tagged"
|
|
15
|
+
*
|
|
16
|
+
* Response:
|
|
17
|
+
* memories Memory[] — source memories included in the prompt
|
|
18
|
+
* prompt string — structured LLM prompt
|
|
19
|
+
* suggestedTags string[] — tags Flair detected in the source set
|
|
20
|
+
* count number — number of memories included
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
24
|
+
import { isAdmin } from "./auth-middleware.js";
|
|
25
|
+
import { patchRecordSilent } from "./table-helpers.js";
|
|
26
|
+
|
|
27
|
+
const FOCUS_PROMPTS: Record<string, string> = {
|
|
28
|
+
lessons_learned:
|
|
29
|
+
"Review these memories and identify concrete lessons learned. For each lesson: what happened, what you learned, and how it should change future behavior. Write atomic memories with durability=persistent.",
|
|
30
|
+
patterns:
|
|
31
|
+
"Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
|
|
32
|
+
decisions:
|
|
33
|
+
"Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
|
|
34
|
+
errors:
|
|
35
|
+
"Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class ReflectMemories extends Resource {
|
|
39
|
+
async post(data: any) {
|
|
40
|
+
const {
|
|
41
|
+
agentId,
|
|
42
|
+
scope = "recent",
|
|
43
|
+
since,
|
|
44
|
+
maxMemories = 50,
|
|
45
|
+
focus = "lessons_learned",
|
|
46
|
+
tag,
|
|
47
|
+
} = data || {};
|
|
48
|
+
|
|
49
|
+
if (!agentId) return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
50
|
+
|
|
51
|
+
// Auth: agent can only reflect on own memories unless admin
|
|
52
|
+
const actorId = (this as any).request?.tpsAgent;
|
|
53
|
+
if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
|
|
54
|
+
return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
|
|
58
|
+
const memories: any[] = [];
|
|
59
|
+
|
|
60
|
+
for await (const record of (databases as any).flair.Memory.search()) {
|
|
61
|
+
if (record.agentId !== agentId) continue;
|
|
62
|
+
if (record.archived) continue;
|
|
63
|
+
if (record.durability === "permanent") continue; // permanent memories don't need reflection
|
|
64
|
+
|
|
65
|
+
if (scope === "tagged") {
|
|
66
|
+
if (!tag || !(record.tags ?? []).includes(tag)) continue;
|
|
67
|
+
} else if (scope === "recent") {
|
|
68
|
+
if (!record.createdAt || new Date(record.createdAt) < sinceDate) continue;
|
|
69
|
+
}
|
|
70
|
+
// scope="all" passes everything
|
|
71
|
+
|
|
72
|
+
const { embedding, ...rest } = record;
|
|
73
|
+
memories.push(rest);
|
|
74
|
+
if (memories.length >= maxMemories) break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
memories.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
78
|
+
|
|
79
|
+
// Collect tags present in source memories
|
|
80
|
+
const tagSet = new Set<string>();
|
|
81
|
+
for (const m of memories) {
|
|
82
|
+
for (const t of m.tags ?? []) tagSet.add(t);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build prompt
|
|
86
|
+
const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
|
|
87
|
+
const memorySummary = memories
|
|
88
|
+
.map((m, i) => `[${i + 1}] (${m.id}) ${m.createdAt?.slice(0, 10) ?? "?"}: ${m.content.slice(0, 300)}`)
|
|
89
|
+
.join("\n");
|
|
90
|
+
|
|
91
|
+
const prompt = `# Memory Reflection — ${agentId}
|
|
92
|
+
Focus: ${focus}
|
|
93
|
+
Scope: ${scope} (since ${sinceDate.toISOString()})
|
|
94
|
+
Memories: ${memories.length}
|
|
95
|
+
|
|
96
|
+
## Task
|
|
97
|
+
${focusText}
|
|
98
|
+
|
|
99
|
+
## Source Memories
|
|
100
|
+
${memorySummary || "(none)"}
|
|
101
|
+
|
|
102
|
+
## Instructions
|
|
103
|
+
For each insight:
|
|
104
|
+
1. Write a new memory with durability=persistent
|
|
105
|
+
2. Set derivedFrom=[<source memory ids>]
|
|
106
|
+
3. Set tags from the source memories where relevant
|
|
107
|
+
4. Keep each memory atomic — one insight per record`;
|
|
108
|
+
|
|
109
|
+
// Update lastReflected on source memories (read-modify-write to preserve embeddings)
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
for (const m of memories) {
|
|
112
|
+
patchRecordSilent((databases as any).flair.Memory, m.id, { lastReflected: now });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
memories,
|
|
117
|
+
prompt,
|
|
118
|
+
suggestedTags: [...tagSet].slice(0, 20),
|
|
119
|
+
count: memories.length,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrgEvent.ts — Harper table resource for org-wide activity events.
|
|
3
|
+
*
|
|
4
|
+
* Auth: Ed25519 middleware sets request.tpsAgent.
|
|
5
|
+
* Write: authorId must match authenticated agent (or admin).
|
|
6
|
+
* Read: any authenticated participant can read (org-scoped).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { databases } from "@harperfast/harper";
|
|
10
|
+
|
|
11
|
+
export class OrgEvent extends (databases as any).flair.OrgEvent {
|
|
12
|
+
async post(content: any, context?: any) {
|
|
13
|
+
const agentId = context?.request?.tpsAgent;
|
|
14
|
+
|
|
15
|
+
// authorId must match authenticated agent (unless admin)
|
|
16
|
+
if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
|
|
17
|
+
return new Response(
|
|
18
|
+
JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }),
|
|
19
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Generate composite ID if not provided
|
|
24
|
+
if (!content.id) {
|
|
25
|
+
content.id = `${content.authorId}-${Date.now()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
content.createdAt = new Date().toISOString();
|
|
29
|
+
|
|
30
|
+
// Harper 5: table resources use put() for create/upsert (post() removed)
|
|
31
|
+
return (databases as any).flair.OrgEvent.put(content);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async put(content: any, context?: any) {
|
|
35
|
+
const agentId = context?.request?.tpsAgent;
|
|
36
|
+
|
|
37
|
+
if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
|
|
38
|
+
return new Response(
|
|
39
|
+
JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }),
|
|
40
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (databases as any).flair.OrgEvent.put(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async delete(id: any, context?: any) {
|
|
48
|
+
const agentId = context?.request?.tpsAgent;
|
|
49
|
+
if (!agentId) return super.delete(id, context);
|
|
50
|
+
|
|
51
|
+
const record = await this.get(id);
|
|
52
|
+
if (!record) return super.delete(id, context);
|
|
53
|
+
|
|
54
|
+
if (!context?.request?.tpsAgentIsAdmin && record.authorId !== agentId) {
|
|
55
|
+
return new Response(
|
|
56
|
+
JSON.stringify({ error: "forbidden: cannot delete events authored by another agent" }),
|
|
57
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return super.delete(id, context);
|
|
62
|
+
}
|
|
63
|
+
}
|