@tpsdev-ai/flair 0.4.16 → 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.
@@ -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
+ }
@@ -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
- * One-call context builder for agent cold starts.
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 (last 24-48h standard/persistent)
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, } = data || {};
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
  },