@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,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
|
+
}
|