@tpsdev-ai/flair 0.4.15 → 0.5.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/README.md +49 -0
- package/dist/cli.js +971 -102
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +5 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
3
|
+
/**
|
|
4
|
+
* Credential resource — authentication surfaces for Principals.
|
|
5
|
+
*
|
|
6
|
+
* A Principal can have multiple credentials: passkeys, bearer tokens,
|
|
7
|
+
* Ed25519 signing keys, and IdP links. This resource manages the
|
|
8
|
+
* credential lifecycle: creation, revocation, and lookup.
|
|
9
|
+
*
|
|
10
|
+
* Only admin principals can create/revoke credentials for other principals.
|
|
11
|
+
* Non-admin principals can only view their own credentials (token hashes
|
|
12
|
+
* are never returned in responses).
|
|
13
|
+
*/
|
|
14
|
+
export class Credential extends databases.flair.Credential {
|
|
15
|
+
async search(query) {
|
|
16
|
+
const ctx = this.getContext?.();
|
|
17
|
+
const request = ctx?.request ?? ctx;
|
|
18
|
+
const authAgent = request?.tpsAgent;
|
|
19
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
20
|
+
if (!authAgent || isAdminAgent) {
|
|
21
|
+
return super.search(query);
|
|
22
|
+
}
|
|
23
|
+
// Non-admin: scope to own credentials
|
|
24
|
+
const condition = { attribute: "principalId", comparator: "equals", value: authAgent };
|
|
25
|
+
if (!query?.conditions) {
|
|
26
|
+
return super.search({ conditions: [condition], ...(query || {}) });
|
|
27
|
+
}
|
|
28
|
+
return super.search({
|
|
29
|
+
...query,
|
|
30
|
+
conditions: [condition, { conditions: query.conditions, operator: query.operator || "and" }],
|
|
31
|
+
operator: "and",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async get() {
|
|
35
|
+
const result = await super.get();
|
|
36
|
+
if (!result)
|
|
37
|
+
return result;
|
|
38
|
+
const ctx = this.getContext?.();
|
|
39
|
+
const request = ctx?.request ?? ctx;
|
|
40
|
+
const authAgent = request?.tpsAgent;
|
|
41
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
42
|
+
// Non-admin can only see their own credentials
|
|
43
|
+
if (authAgent && !isAdminAgent && result.principalId !== authAgent) {
|
|
44
|
+
return new Response(JSON.stringify({ error: "forbidden" }), {
|
|
45
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Never return token hashes
|
|
49
|
+
const { tokenHash, ...safe } = result;
|
|
50
|
+
return safe;
|
|
51
|
+
}
|
|
52
|
+
async put(content) {
|
|
53
|
+
const ctx = this.getContext?.();
|
|
54
|
+
const request = ctx?.request ?? ctx;
|
|
55
|
+
const authAgent = request?.tpsAgent;
|
|
56
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
57
|
+
if (!authAgent) {
|
|
58
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
59
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Only admins can create credentials for other principals
|
|
63
|
+
if (!isAdminAgent && content.principalId && content.principalId !== authAgent) {
|
|
64
|
+
return new Response(JSON.stringify({ error: "only admin principals can manage other principals' credentials" }), {
|
|
65
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const rl = checkRateLimit(authAgent);
|
|
69
|
+
if (!rl.allowed)
|
|
70
|
+
return rateLimitResponse(rl.retryAfterMs, "credential");
|
|
71
|
+
// Validate kind
|
|
72
|
+
const validKinds = ["webauthn", "bearer-token", "ed25519", "idp"];
|
|
73
|
+
if (!content.kind || !validKinds.includes(content.kind)) {
|
|
74
|
+
return new Response(JSON.stringify({ error: `kind must be one of: ${validKinds.join(", ")}` }), {
|
|
75
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
content.principalId = content.principalId || authAgent;
|
|
80
|
+
content.status = content.status || "active";
|
|
81
|
+
content.createdAt = content.createdAt || now;
|
|
82
|
+
content.updatedAt = now;
|
|
83
|
+
return super.put(content);
|
|
84
|
+
}
|
|
85
|
+
async delete(_) {
|
|
86
|
+
const ctx = this.getContext?.();
|
|
87
|
+
const request = ctx?.request ?? ctx;
|
|
88
|
+
const authAgent = request?.tpsAgent;
|
|
89
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
90
|
+
if (!authAgent) {
|
|
91
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
92
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!isAdminAgent) {
|
|
96
|
+
const existing = await super.get();
|
|
97
|
+
if (existing?.principalId && existing.principalId !== authAgent) {
|
|
98
|
+
return new Response(JSON.stringify({ error: "only admin principals can revoke other principals' credentials" }), {
|
|
99
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return super.delete(_);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import nacl from "tweetnacl";
|
|
4
|
+
import { canonicalize, signBody, verifyBodySignature } from "./federation-crypto.js";
|
|
5
|
+
// Re-export for consumers that import from Federation.ts
|
|
6
|
+
export { canonicalize, signBody, verifyBodySignature };
|
|
7
|
+
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
8
|
+
/**
|
|
9
|
+
* Field-level Last-Write-Wins merge.
|
|
10
|
+
* For each field, the value with the later `updatedAt` wins.
|
|
11
|
+
* Records with no local counterpart are accepted directly.
|
|
12
|
+
*/
|
|
13
|
+
function mergeRecord(local, remote) {
|
|
14
|
+
if (!local)
|
|
15
|
+
return remote.data;
|
|
16
|
+
const merged = { ...local };
|
|
17
|
+
const localUpdated = local.updatedAt ?? "";
|
|
18
|
+
const remoteUpdated = remote.updatedAt ?? "";
|
|
19
|
+
// Simple LWW at record level for 1.0
|
|
20
|
+
// Field-level LWW is the spec target but record-level is sufficient
|
|
21
|
+
// for the initial implementation and avoids per-field clock tracking.
|
|
22
|
+
if (remoteUpdated > localUpdated) {
|
|
23
|
+
Object.assign(merged, remote.data);
|
|
24
|
+
merged.updatedAt = remoteUpdated;
|
|
25
|
+
}
|
|
26
|
+
return merged;
|
|
27
|
+
}
|
|
28
|
+
// ─── Instance identity ───────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* GET /FederationInstance — return this instance's identity.
|
|
31
|
+
* Used by peers during pairing and by the admin UI.
|
|
32
|
+
*/
|
|
33
|
+
export class FederationInstance extends Resource {
|
|
34
|
+
async get() {
|
|
35
|
+
// Find or create instance identity
|
|
36
|
+
let instance = null;
|
|
37
|
+
try {
|
|
38
|
+
for await (const i of databases.flair.Instance.search()) {
|
|
39
|
+
instance = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* table may not exist */ }
|
|
44
|
+
if (!instance) {
|
|
45
|
+
// First boot — generate instance identity
|
|
46
|
+
const kp = nacl.sign.keyPair();
|
|
47
|
+
const id = `flair_${randomBytes(4).toString("hex")}`;
|
|
48
|
+
const publicKey = Buffer.from(kp.publicKey).toString("base64url");
|
|
49
|
+
instance = {
|
|
50
|
+
id,
|
|
51
|
+
publicKey,
|
|
52
|
+
role: "spoke", // default; hub is set during `flair init --remote`
|
|
53
|
+
status: "active",
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
await databases.flair.Instance.put(instance);
|
|
58
|
+
// Store private key seed in encrypted keystore (not in DB)
|
|
59
|
+
try {
|
|
60
|
+
const { keystore } = await import("../src/keystore.js");
|
|
61
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
62
|
+
keystore.setPrivateKeySeed(id, seed);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// Fail closed — never store plaintext keys in the database
|
|
66
|
+
console.error("[federation] FATAL: Could not store key seed in keystore. Federation identity not created.", err);
|
|
67
|
+
throw new Error("Keystore unavailable — cannot create federation identity without secure key storage");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
id: instance.id,
|
|
72
|
+
publicKey: instance.publicKey,
|
|
73
|
+
role: instance.role,
|
|
74
|
+
status: instance.status,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ─── Peer management ─────────────────────────────────────────────────────────
|
|
79
|
+
/**
|
|
80
|
+
* POST /FederationPair — one-shot pairing handshake.
|
|
81
|
+
* A spoke sends its instance identity; the hub records it as a peer.
|
|
82
|
+
* Requires a one-time pairing token generated by `flair federation token`.
|
|
83
|
+
*/
|
|
84
|
+
export class FederationPair extends Resource {
|
|
85
|
+
async post(data) {
|
|
86
|
+
const { instanceId, publicKey, role, endpoint, signature, pairingToken } = data || {};
|
|
87
|
+
if (!instanceId || !publicKey) {
|
|
88
|
+
return new Response(JSON.stringify({ error: "instanceId and publicKey required" }), {
|
|
89
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Verify signature — the pairing body must be signed with the private key
|
|
93
|
+
// corresponding to the publicKey in the body (proves key ownership).
|
|
94
|
+
if (!signature) {
|
|
95
|
+
return new Response(JSON.stringify({ error: "signature required — request must be signed" }), {
|
|
96
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!verifyBodySignature(data, publicKey)) {
|
|
100
|
+
return new Response(JSON.stringify({ error: "invalid signature — key ownership proof failed" }), {
|
|
101
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Check if already paired (re-pairing doesn't need a token)
|
|
105
|
+
const existing = await databases.flair.Peer.get(instanceId);
|
|
106
|
+
if (existing) {
|
|
107
|
+
// Re-pairing: verify public key matches (prevents impersonation)
|
|
108
|
+
if (existing.publicKey !== publicKey) {
|
|
109
|
+
return new Response(JSON.stringify({
|
|
110
|
+
error: "public key mismatch — peer already paired with different key",
|
|
111
|
+
}), { status: 409, headers: { "content-type": "application/json" } });
|
|
112
|
+
}
|
|
113
|
+
// Update endpoint and status
|
|
114
|
+
await databases.flair.Peer.put({
|
|
115
|
+
...existing,
|
|
116
|
+
endpoint: endpoint ?? existing.endpoint,
|
|
117
|
+
status: "paired",
|
|
118
|
+
updatedAt: new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// New peer — require a valid pairing token
|
|
123
|
+
if (!pairingToken) {
|
|
124
|
+
return new Response(JSON.stringify({
|
|
125
|
+
error: "pairingToken required — generate one with 'flair federation token'",
|
|
126
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
127
|
+
}
|
|
128
|
+
// Validate and consume the token
|
|
129
|
+
let token = null;
|
|
130
|
+
try {
|
|
131
|
+
token = await databases.flair.PairingToken.get(pairingToken);
|
|
132
|
+
}
|
|
133
|
+
catch { /* table may not exist */ }
|
|
134
|
+
if (!token) {
|
|
135
|
+
return new Response(JSON.stringify({ error: "invalid pairing token" }), {
|
|
136
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (token.consumedBy) {
|
|
140
|
+
return new Response(JSON.stringify({ error: "pairing token already used" }), {
|
|
141
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (token.expiresAt && new Date(token.expiresAt) < new Date()) {
|
|
145
|
+
return new Response(JSON.stringify({ error: "pairing token expired" }), {
|
|
146
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Consume the token
|
|
150
|
+
await databases.flair.PairingToken.put({
|
|
151
|
+
...token,
|
|
152
|
+
consumedBy: instanceId,
|
|
153
|
+
consumedAt: new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
await databases.flair.Peer.put({
|
|
156
|
+
id: instanceId,
|
|
157
|
+
publicKey,
|
|
158
|
+
role: role ?? "spoke",
|
|
159
|
+
endpoint: endpoint ?? null,
|
|
160
|
+
status: "paired",
|
|
161
|
+
relayOnly: false,
|
|
162
|
+
pairedAt: new Date().toISOString(),
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
updatedAt: new Date().toISOString(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Return our own identity for the peer to record
|
|
168
|
+
let ourInstance = null;
|
|
169
|
+
try {
|
|
170
|
+
for await (const i of databases.flair.Instance.search()) {
|
|
171
|
+
ourInstance = i;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
return {
|
|
177
|
+
paired: true,
|
|
178
|
+
instance: ourInstance ? {
|
|
179
|
+
id: ourInstance.id,
|
|
180
|
+
publicKey: ourInstance.publicKey,
|
|
181
|
+
role: ourInstance.role,
|
|
182
|
+
} : null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ─── Sync endpoint ───────────────────────────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* POST /FederationSync — push sync records from a peer.
|
|
189
|
+
* In 1.0, this is a simple HTTP push (not WebSocket).
|
|
190
|
+
* The calling peer sends a batch of SyncRecords; we merge them.
|
|
191
|
+
*/
|
|
192
|
+
export class FederationSync extends Resource {
|
|
193
|
+
async post(data) {
|
|
194
|
+
const { instanceId, records, lamportClock, signature } = data || {};
|
|
195
|
+
if (!instanceId || !Array.isArray(records)) {
|
|
196
|
+
return new Response(JSON.stringify({ error: "instanceId and records[] required" }), {
|
|
197
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Verify peer is known
|
|
201
|
+
const peer = await databases.flair.Peer.get(instanceId);
|
|
202
|
+
if (!peer || peer.status === "revoked") {
|
|
203
|
+
return new Response(JSON.stringify({ error: "unknown or revoked peer" }), {
|
|
204
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Verify request signature against peer's pinned public key
|
|
208
|
+
if (!signature) {
|
|
209
|
+
return new Response(JSON.stringify({ error: "signature required — sync requests must be signed" }), {
|
|
210
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (!verifyBodySignature(data, peer.publicKey)) {
|
|
214
|
+
return new Response(JSON.stringify({ error: "invalid signature — request not from claimed peer" }), {
|
|
215
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const startTime = Date.now();
|
|
219
|
+
let merged = 0;
|
|
220
|
+
let skipped = 0;
|
|
221
|
+
// Table name → Harper database table mapping
|
|
222
|
+
const tableMap = {
|
|
223
|
+
Memory: databases.flair.Memory,
|
|
224
|
+
Soul: databases.flair.Soul,
|
|
225
|
+
Agent: databases.flair.Agent,
|
|
226
|
+
Relationship: databases.flair.Relationship,
|
|
227
|
+
};
|
|
228
|
+
for (const record of records) {
|
|
229
|
+
const table = tableMap[record.table];
|
|
230
|
+
if (!table) {
|
|
231
|
+
skipped++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Originator enforcement: peers can only push records they originated.
|
|
235
|
+
// The hub can relay records from other peers (role === "hub"),
|
|
236
|
+
// but spokes can only push their own records.
|
|
237
|
+
const originator = record.originatorInstanceId ?? instanceId;
|
|
238
|
+
if (originator !== instanceId && peer.role !== "hub") {
|
|
239
|
+
skipped++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const local = await table.get(record.id);
|
|
244
|
+
// Timestamp ceiling: reject records with updatedAt more than 5 minutes in the future.
|
|
245
|
+
// Prevents attackers from using far-future timestamps to permanently win LWW.
|
|
246
|
+
const fiveMinFromNow = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
247
|
+
if (record.updatedAt > fiveMinFromNow) {
|
|
248
|
+
skipped++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const mergedData = mergeRecord(local, record);
|
|
252
|
+
// Preserve originator for provenance
|
|
253
|
+
mergedData._originatorInstanceId = originator;
|
|
254
|
+
mergedData._syncedFrom = instanceId;
|
|
255
|
+
mergedData._syncedAt = new Date().toISOString();
|
|
256
|
+
await table.put(mergedData);
|
|
257
|
+
merged++;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
skipped++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Update peer sync cursor
|
|
264
|
+
await databases.flair.Peer.put({
|
|
265
|
+
...peer,
|
|
266
|
+
lastSyncAt: new Date().toISOString(),
|
|
267
|
+
lastSyncCursor: lamportClock?.toString() ?? new Date().toISOString(),
|
|
268
|
+
status: "connected",
|
|
269
|
+
updatedAt: new Date().toISOString(),
|
|
270
|
+
});
|
|
271
|
+
// Log sync operation
|
|
272
|
+
try {
|
|
273
|
+
await databases.flair.SyncLog.put({
|
|
274
|
+
id: `sync_${Date.now()}_${randomBytes(4).toString("hex")}`,
|
|
275
|
+
peerId: instanceId,
|
|
276
|
+
direction: "pull",
|
|
277
|
+
recordCount: merged,
|
|
278
|
+
status: skipped > 0 ? "partial" : "success",
|
|
279
|
+
error: skipped > 0 ? `${skipped} records skipped` : undefined,
|
|
280
|
+
durationMs: Date.now() - startTime,
|
|
281
|
+
createdAt: new Date().toISOString(),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch { /* non-fatal */ }
|
|
285
|
+
return {
|
|
286
|
+
merged,
|
|
287
|
+
skipped,
|
|
288
|
+
total: records.length,
|
|
289
|
+
durationMs: Date.now() - startTime,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* GET /FederationPeers — list known peers (admin view).
|
|
295
|
+
*/
|
|
296
|
+
export class FederationPeers extends Resource {
|
|
297
|
+
async get() {
|
|
298
|
+
const peers = [];
|
|
299
|
+
try {
|
|
300
|
+
for await (const p of databases.flair.Peer.search()) {
|
|
301
|
+
peers.push({
|
|
302
|
+
id: p.id,
|
|
303
|
+
role: p.role,
|
|
304
|
+
status: p.status,
|
|
305
|
+
endpoint: p.endpoint,
|
|
306
|
+
lastSyncAt: p.lastSyncAt,
|
|
307
|
+
relayOnly: p.relayOnly,
|
|
308
|
+
pairedAt: p.pairedAt,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
return { peers };
|
|
314
|
+
}
|
|
315
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -88,6 +88,17 @@ export class Memory extends databases.flair.Memory {
|
|
|
88
88
|
status: 400, headers: { "Content-Type": "application/json" },
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
|
+
// Temporal validity: validFrom defaults to now, validTo left null for active facts.
|
|
92
|
+
// When a memory supersedes another, close the superseded memory's validity window.
|
|
93
|
+
if (!content.validFrom) {
|
|
94
|
+
content.validFrom = content.createdAt;
|
|
95
|
+
}
|
|
96
|
+
if (content.supersedes) {
|
|
97
|
+
patchRecord(databases.flair.Memory, content.supersedes, {
|
|
98
|
+
validTo: content.validFrom,
|
|
99
|
+
updatedAt: content.createdAt,
|
|
100
|
+
}).catch(() => { });
|
|
101
|
+
}
|
|
91
102
|
if (content.durability === "ephemeral" && !content.expiresAt) {
|
|
92
103
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
93
104
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
@@ -4,15 +4,22 @@ import { wrapUntrusted } from "./content-safety.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* POST /MemoryBootstrap
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Predictive context builder for agent session starts.
|
|
8
8
|
* Returns prioritized, token-budgeted context with:
|
|
9
9
|
* 1. Soul records (identity, role, preferences)
|
|
10
10
|
* 2. Permanent memories (safety rules, core principles)
|
|
11
|
-
* 3. Recent memories (
|
|
11
|
+
* 3. Recent memories (adaptive window)
|
|
12
12
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
13
|
+
* 5. Relationship context (active relationships for mentioned entities)
|
|
14
|
+
* 6. Predicted context (based on channel/surface/subject hints)
|
|
15
|
+
*
|
|
16
|
+
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
17
|
+
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
18
|
+
* bottleneck is prediction quality, not load time.
|
|
13
19
|
*
|
|
14
20
|
* Request:
|
|
15
|
-
* { agentId, currentTask?, maxTokens?, includeSoul?, since
|
|
21
|
+
* { agentId, currentTask?, maxTokens?, includeSoul?, since?,
|
|
22
|
+
* channel?, surface?, subjects? }
|
|
16
23
|
*
|
|
17
24
|
* Response:
|
|
18
25
|
* { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
|
|
@@ -34,7 +41,10 @@ function formatMemory(m, supersedes) {
|
|
|
34
41
|
}
|
|
35
42
|
export class BootstrapMemories extends Resource {
|
|
36
43
|
async post(data, _context) {
|
|
37
|
-
const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since,
|
|
44
|
+
const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
45
|
+
surface, // e.g., "tps-build", "tps-review", "cli-session"
|
|
46
|
+
subjects, // e.g., ["flair", "auth"] — entities to preload context for
|
|
47
|
+
} = data || {};
|
|
38
48
|
if (!agentId) {
|
|
39
49
|
return new Response(JSON.stringify({ error: "agentId required" }), {
|
|
40
50
|
status: 400,
|
|
@@ -54,6 +64,8 @@ export class BootstrapMemories extends Resource {
|
|
|
54
64
|
skills: [],
|
|
55
65
|
permanent: [],
|
|
56
66
|
recent: [],
|
|
67
|
+
predicted: [],
|
|
68
|
+
relationships: [],
|
|
57
69
|
relevant: [],
|
|
58
70
|
events: [],
|
|
59
71
|
};
|
|
@@ -202,6 +214,73 @@ export class BootstrapMemories extends Resource {
|
|
|
202
214
|
tokenBudget -= cost;
|
|
203
215
|
memoriesIncluded++;
|
|
204
216
|
}
|
|
217
|
+
// --- 3b. Subject-predicted context ---
|
|
218
|
+
// When subjects are provided (e.g., ["flair", "auth"]), load memories
|
|
219
|
+
// tagged with those subjects that aren't already included. This is the
|
|
220
|
+
// "predictive" part — the caller knows what topics are likely relevant
|
|
221
|
+
// based on channel/surface/recent-activity.
|
|
222
|
+
const predictedSubjects = Array.isArray(subjects)
|
|
223
|
+
? subjects.map((s) => s.toLowerCase())
|
|
224
|
+
: [];
|
|
225
|
+
if (predictedSubjects.length > 0 && tokenBudget > 200) {
|
|
226
|
+
const includedIds = new Set([
|
|
227
|
+
...permanent.map((m) => m.id),
|
|
228
|
+
...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
|
|
229
|
+
]);
|
|
230
|
+
const subjectMemories = activeMemories
|
|
231
|
+
.filter((m) => !includedIds.has(m.id) &&
|
|
232
|
+
m.subject &&
|
|
233
|
+
predictedSubjects.includes(m.subject.toLowerCase()) &&
|
|
234
|
+
m.durability !== "permanent" // already loaded
|
|
235
|
+
)
|
|
236
|
+
.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
237
|
+
const predictedBudget = Math.floor(tokenBudget * 0.3);
|
|
238
|
+
let predictedSpent = 0;
|
|
239
|
+
for (const m of subjectMemories) {
|
|
240
|
+
const line = formatMemory(m);
|
|
241
|
+
const cost = estimateTokens(line);
|
|
242
|
+
if (predictedSpent + cost > predictedBudget)
|
|
243
|
+
continue;
|
|
244
|
+
sections.predicted.push(line);
|
|
245
|
+
predictedSpent += cost;
|
|
246
|
+
tokenBudget -= cost;
|
|
247
|
+
memoriesIncluded++;
|
|
248
|
+
includedIds.add(m.id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// --- 3c. Active relationships for predicted subjects ---
|
|
252
|
+
if (predictedSubjects.length > 0 && tokenBudget > 100) {
|
|
253
|
+
try {
|
|
254
|
+
for (const subj of predictedSubjects) {
|
|
255
|
+
for await (const rel of databases.flair.Relationship.search({
|
|
256
|
+
conditions: [
|
|
257
|
+
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
258
|
+
{
|
|
259
|
+
operator: "or",
|
|
260
|
+
conditions: [
|
|
261
|
+
{ attribute: "subject", comparator: "equals", value: subj },
|
|
262
|
+
{ attribute: "object", comparator: "equals", value: subj },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
operator: "and",
|
|
267
|
+
})) {
|
|
268
|
+
// Only include active relationships (no validTo or validTo in future)
|
|
269
|
+
if (rel.validTo && rel.validTo < new Date().toISOString())
|
|
270
|
+
continue;
|
|
271
|
+
const line = `- ${rel.subject} → ${rel.predicate} → ${rel.object}${rel.confidence < 1.0 ? ` (${Math.round(rel.confidence * 100)}%)` : ""}`;
|
|
272
|
+
const cost = estimateTokens(line);
|
|
273
|
+
if (cost > tokenBudget)
|
|
274
|
+
break;
|
|
275
|
+
sections.relationships.push(line);
|
|
276
|
+
tokenBudget -= cost;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Relationship table may not exist yet
|
|
282
|
+
}
|
|
283
|
+
}
|
|
205
284
|
// --- 4. Task-relevant memories (semantic search) ---
|
|
206
285
|
if (currentTask && tokenBudget > 200) {
|
|
207
286
|
let queryEmbedding = null;
|
|
@@ -280,6 +359,12 @@ export class BootstrapMemories extends Resource {
|
|
|
280
359
|
if (sections.recent.length > 0) {
|
|
281
360
|
parts.push("## Recent Context\n" + sections.recent.join("\n"));
|
|
282
361
|
}
|
|
362
|
+
if (sections.predicted.length > 0) {
|
|
363
|
+
parts.push("## Predicted Context\n" + sections.predicted.join("\n"));
|
|
364
|
+
}
|
|
365
|
+
if (sections.relationships.length > 0) {
|
|
366
|
+
parts.push("## Active Relationships\n" + sections.relationships.join("\n"));
|
|
367
|
+
}
|
|
283
368
|
if (sections.relevant.length > 0) {
|
|
284
369
|
parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
|
|
285
370
|
}
|
|
@@ -296,6 +381,8 @@ export class BootstrapMemories extends Resource {
|
|
|
296
381
|
skills: sections.skills.length,
|
|
297
382
|
permanent: sections.permanent.length,
|
|
298
383
|
recent: sections.recent.length,
|
|
384
|
+
predicted: sections.predicted.length,
|
|
385
|
+
relationships: sections.relationships.length,
|
|
299
386
|
relevant: sections.relevant.length,
|
|
300
387
|
events: sections.events.length,
|
|
301
388
|
},
|