@tpsdev-ai/flair 0.10.1 → 0.11.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/dist/cli.js CHANGED
@@ -3454,24 +3454,35 @@ export async function runFederationSyncOnce(opts) {
3454
3454
  }
3455
3455
  let totalBatches = 0;
3456
3456
  for (const table of tables) {
3457
- let res;
3458
- try {
3459
- res = await fetch(`${opsEndpoint}/`, {
3460
- method: "POST",
3461
- headers: { "Content-Type": "application/json", Authorization: auth },
3462
- body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
3463
- signal: AbortSignal.timeout(15_000),
3464
- });
3465
- }
3466
- catch (err) {
3467
- return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3468
- }
3469
- if (!res.ok) {
3470
- const text = await res.text().catch(() => "");
3471
- return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3457
+ let rows = [];
3458
+ for (const query of [
3459
+ { search_attribute: "updatedAt", search_type: "greater_than", search_value: since },
3460
+ // Rows with null updatedAt (legacy direct-insert rows) use createdAt.
3461
+ // COALESCE(updatedAt, createdAt) > since pick up null-updatedAt rows
3462
+ // whose createdAt > since. Filtered in JS below.
3463
+ { search_attribute: "updatedAt", search_type: "equals", search_value: null },
3464
+ ]) {
3465
+ let res;
3466
+ try {
3467
+ res = await fetch(`${opsEndpoint}/`, {
3468
+ method: "POST",
3469
+ headers: { "Content-Type": "application/json", Authorization: auth },
3470
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [query], get_attributes: ["*"] }),
3471
+ signal: AbortSignal.timeout(15_000),
3472
+ });
3473
+ }
3474
+ catch (err) {
3475
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3476
+ }
3477
+ if (!res.ok) {
3478
+ const text = await res.text().catch(() => "");
3479
+ return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3480
+ }
3481
+ const batch = await res.json();
3482
+ // For null-updatedAt rows, use createdAt as the effective timestamp.
3483
+ // Skip rows created before the last sync cursor.
3484
+ rows = rows.concat(batch.filter((r) => r.updatedAt !== null || r.createdAt > since));
3472
3485
  }
3473
- // Stream-collect records into batches
3474
- const rows = await res.json();
3475
3486
  if (rows.length === 0)
3476
3487
  continue;
3477
3488
  let batch = [];
@@ -3528,6 +3539,31 @@ export async function runFederationSyncOnce(opts) {
3528
3539
  console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3529
3540
  }
3530
3541
  if (totalBatches === 0) {
3542
+ // ops-c7t9: no-change syncs must still ping the hub so it updates the
3543
+ // spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
3544
+ // look indistinguishable from dead ones on the hub dashboard.
3545
+ try {
3546
+ if (!secretKey)
3547
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3548
+ const pingBody = signBodyFresh({
3549
+ instanceId: instance.id,
3550
+ records: [],
3551
+ lamportClock: Date.now(),
3552
+ }, secretKey);
3553
+ const pingRes = await fetch(`${hubUrl}/FederationSync`, {
3554
+ method: "POST",
3555
+ headers: { "Content-Type": "application/json" },
3556
+ body: JSON.stringify(pingBody),
3557
+ signal: AbortSignal.timeout(10_000),
3558
+ });
3559
+ if (!pingRes.ok) {
3560
+ const txt = await pingRes.text().catch(() => "");
3561
+ console.warn(`⚠️ Liveness ping to hub failed (${pingRes.status}): ${txt.slice(0, 200)}. Hub won't update spoke liveness.`);
3562
+ }
3563
+ }
3564
+ catch (pingErr) {
3565
+ console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
3566
+ }
3531
3567
  console.log("No changes since last sync.");
3532
3568
  return { pushed: 0, skipped: 0 };
3533
3569
  }
@@ -8815,9 +8851,74 @@ program
8815
8851
  process.exit(1);
8816
8852
  }
8817
8853
  });
8854
+ // ─── flair presence ─────────────────────────────────────────────────────────
8855
+ const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
8856
+ const MAX_TASK_LENGTH = 120;
8857
+ const presence = program.command("presence").description("Manage agent presence (The Office Space)");
8858
+ presence
8859
+ .command("set")
8860
+ .description("Set your agent's current activity and task (POST /Presence)")
8861
+ .option("--activity <activity>", `Activity type (${VALID_PRESENCE_ACTIVITIES.join("|")})`)
8862
+ .option("--task <text>", "Short description of what you're working on")
8863
+ .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
8864
+ .option("--port <port>", "Harper HTTP port")
8865
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
8866
+ .action(async (opts) => {
8867
+ const agentId = resolveAgentIdOrEnv(opts);
8868
+ if (!agentId) {
8869
+ console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
8870
+ process.exit(1);
8871
+ }
8872
+ // Validate activity
8873
+ if (!opts.activity) {
8874
+ console.error("Error: --activity is required.");
8875
+ process.exit(1);
8876
+ }
8877
+ const activity = opts.activity;
8878
+ if (!VALID_PRESENCE_ACTIVITIES.includes(activity)) {
8879
+ console.error(`Error: invalid activity '${activity}'. Must be one of: ${VALID_PRESENCE_ACTIVITIES.join(", ")}`);
8880
+ process.exit(1);
8881
+ }
8882
+ // Validate task length
8883
+ const task = opts.task ?? undefined;
8884
+ if (task && task.length > MAX_TASK_LENGTH) {
8885
+ console.error(`Error: --task exceeds ${MAX_TASK_LENGTH} character limit (got ${task.length}).`);
8886
+ process.exit(1);
8887
+ }
8888
+ // Resolve key path
8889
+ const keyPath = resolveKeyPath(agentId);
8890
+ if (!keyPath) {
8891
+ console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
8892
+ process.exit(1);
8893
+ }
8894
+ // Build auth + POST
8895
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
8896
+ const auth = buildEd25519Auth(agentId, "POST", "/Presence", keyPath);
8897
+ const body = { activity };
8898
+ if (task)
8899
+ body.currentTask = task;
8900
+ const res = await fetch(`${baseUrl}/Presence`, {
8901
+ method: "POST",
8902
+ headers: {
8903
+ "Content-Type": "application/json",
8904
+ Authorization: auth,
8905
+ },
8906
+ body: JSON.stringify(body),
8907
+ });
8908
+ if (!res.ok) {
8909
+ const text = await res.text().catch(() => "");
8910
+ console.error(`Error: POST /Presence failed (${res.status}): ${text}`);
8911
+ process.exit(1);
8912
+ }
8913
+ const data = await res.json().catch(() => null);
8914
+ console.log(`✓ Presence updated for '${agentId}': activity=${activity}${task ? `, task="${task}"` : ""}`);
8915
+ if (data?.presenceStatus) {
8916
+ console.log(` Status: ${data.presenceStatus}`);
8917
+ }
8918
+ });
8818
8919
  // Run CLI only when this is the entry point (not when imported for testing)
8819
8920
  if (import.meta.main) {
8820
8921
  await program.parseAsync();
8821
8922
  }
8822
8923
  // ─── Exported for testing ─────────────────────────────────────────────────────
8823
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
8924
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * POST /Presence — agent heartbeat writes its own presence.
3
+ * GET /Presence — public-safe presence roster for The Office Space.
4
+ *
5
+ * Extends the auto-generated Presence table resource (from schema.graphql).
6
+ * Overrides get() for public-safe roster and post() for Ed25519-authed
7
+ * heartbeat writes.
8
+ *
9
+ * Auth:
10
+ * GET — public (returns only allowlisted fields; safe for public renderer).
11
+ * POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
12
+ * own record; cross-agent writes are rejected (403).
13
+ *
14
+ * Security (Sherlock):
15
+ * - Write: per-agent Ed25519 auth. Cross-agent → 403.
16
+ * - Read: field-allowlisted to public-safe set. No secrets, no admin data.
17
+ * - currentTask is agent-authored free text → cap length, escape on render.
18
+ */
19
+ import { databases } from "@harperfast/harper";
20
+ // ─── Constants ────────────────────────────────────────────────────────────────
21
+ const WINDOW_MS = 30_000;
22
+ const CURRENT_TASK_MAX_LENGTH = 200;
23
+ const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
24
+ function idleThresholdMs() {
25
+ const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
26
+ return env ? Number(env) || 90_000 : 90_000;
27
+ }
28
+ function offlineThresholdMs() {
29
+ const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
30
+ return env ? Number(env) || 600_000 : 600_000;
31
+ }
32
+ // ─── Nonce replay protection ──────────────────────────────────────────────────
33
+ const nonceSeen = new Map();
34
+ function pruneNonces() {
35
+ const now = Date.now();
36
+ for (const [k, ts] of nonceSeen.entries()) {
37
+ if (now - ts > WINDOW_MS)
38
+ nonceSeen.delete(k);
39
+ }
40
+ }
41
+ // ─── Crypto helpers ───────────────────────────────────────────────────────────
42
+ function b64ToArrayBuffer(b64) {
43
+ const std = b64.replace(/-/g, "+").replace(/_/g, "/");
44
+ const bin = atob(std);
45
+ const buf = new ArrayBuffer(bin.length);
46
+ const view = new Uint8Array(buf);
47
+ for (let i = 0; i < bin.length; i++)
48
+ view[i] = bin.charCodeAt(i);
49
+ return buf;
50
+ }
51
+ const keyCache = new Map();
52
+ async function importEd25519Key(publicKeyStr) {
53
+ if (keyCache.has(publicKeyStr))
54
+ return keyCache.get(publicKeyStr);
55
+ let raw;
56
+ if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
57
+ const bytes = new Uint8Array(32);
58
+ for (let i = 0; i < 32; i++)
59
+ bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
60
+ raw = bytes.buffer;
61
+ }
62
+ else {
63
+ raw = b64ToArrayBuffer(publicKeyStr);
64
+ }
65
+ const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
66
+ keyCache.set(publicKeyStr, key);
67
+ return key;
68
+ }
69
+ // ─── Status derivation (pure — exported for unit testing) ─────────────────────
70
+ export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
71
+ const idle = idleMs ?? idleThresholdMs();
72
+ const offline = offlineMs ?? offlineThresholdMs();
73
+ if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt))
74
+ return "offline";
75
+ const elapsed = now - lastHeartbeatAt;
76
+ if (elapsed < 0)
77
+ return "active";
78
+ if (elapsed < idle)
79
+ return "active";
80
+ if (elapsed < offline)
81
+ return "idle";
82
+ return "offline";
83
+ }
84
+ // ─── Public-safe field allowlist ──────────────────────────────────────────────
85
+ const ROSTER_ALLOWLIST = new Set([
86
+ "id",
87
+ "displayName",
88
+ "role",
89
+ "runtime",
90
+ "activity",
91
+ "presenceStatus",
92
+ "currentTask",
93
+ "lastHeartbeatAt",
94
+ ]);
95
+ function sanitizeCurrentTask(task) {
96
+ if (typeof task !== "string")
97
+ return null;
98
+ const trimmed = task.trim();
99
+ if (trimmed.length === 0)
100
+ return null;
101
+ return trimmed.slice(0, CURRENT_TASK_MAX_LENGTH);
102
+ }
103
+ function pickAllowlisted(record) {
104
+ const out = {};
105
+ for (const key of Object.keys(record)) {
106
+ if (ROSTER_ALLOWLIST.has(key))
107
+ out[key] = record[key];
108
+ }
109
+ return out;
110
+ }
111
+ // ─── Resource ─────────────────────────────────────────────────────────────────
112
+ /**
113
+ * Extends the auto-generated Presence table resource (from schema.graphql) so
114
+ * Harper resolves the /Presence path without conflict. Overrides get() for the
115
+ * public-safe roster view and post() for Ed25519-authed heartbeat writes.
116
+ */
117
+ export class Presence extends databases.flair.Presence {
118
+ /** Bypass Harper's role gate for GET (public-safe data only). */
119
+ allowRead() {
120
+ return true;
121
+ }
122
+ /** Bypass Harper's role gate for POST (Ed25519 auth handled internally). */
123
+ allowCreate() {
124
+ return true;
125
+ }
126
+ /**
127
+ * GET /Presence — public-safe presence roster.
128
+ *
129
+ * Joins Presence records with Agent metadata and derives presenceStatus.
130
+ * Only allowlisted fields are returned (no secrets, no admin data).
131
+ */
132
+ async get() {
133
+ const now = Date.now();
134
+ const idleThreshold = idleThresholdMs();
135
+ const offlineThreshold = offlineThresholdMs();
136
+ const results = [];
137
+ try {
138
+ const presenceRows = databases.flair.Presence.search();
139
+ for await (const row of presenceRows) {
140
+ const agentId = row?.agentId;
141
+ if (!agentId)
142
+ continue;
143
+ let agent = null;
144
+ try {
145
+ agent = await databases.flair.Agent.get(agentId);
146
+ }
147
+ catch { /* agent may not exist or be deactivated */ }
148
+ const entry = {
149
+ id: agentId,
150
+ displayName: agent?.displayName ?? agent?.name ?? agentId,
151
+ role: agent?.role ?? "agent",
152
+ runtime: agent?.runtime ?? null,
153
+ activity: row?.activity ?? "idle",
154
+ presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
155
+ ? row.lastHeartbeatAt
156
+ : Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
157
+ currentTask: sanitizeCurrentTask(row?.currentTask),
158
+ lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
159
+ ? row.lastHeartbeatAt
160
+ : Number(row?.lastHeartbeatAt ?? 0),
161
+ };
162
+ results.push(pickAllowlisted(entry));
163
+ }
164
+ }
165
+ catch (err) {
166
+ return new Response(JSON.stringify({ error: "presence_query_failed", detail: err?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
167
+ }
168
+ return results;
169
+ }
170
+ /**
171
+ * POST /Presence — agent heartbeat.
172
+ *
173
+ * Auth: TPS-Ed25519 header required. agentId from signature, NOT from body.
174
+ * An agent may update ONLY its own presence; cross-agent writes → 403.
175
+ *
176
+ * Bypasses Harper's default table post handler — writes via databases.flair.Presence
177
+ * directly so we control auth flow end-to-end.
178
+ */
179
+ async post(content, context) {
180
+ const ctx = this.getContext?.();
181
+ const request = ctx?.request ?? ctx;
182
+ // ── Parse Ed25519 auth header ────────────────────────────────────────────
183
+ const authHeader = request?.headers?.get?.("authorization") ??
184
+ request?.headers?.asObject?.authorization ??
185
+ "";
186
+ // If the middleware already verified and set tpsAgent, trust it
187
+ const middlewareAgent = request?.tpsAgent;
188
+ let agentId;
189
+ if (middlewareAgent) {
190
+ agentId = middlewareAgent;
191
+ }
192
+ else {
193
+ const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
194
+ if (!m) {
195
+ return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
196
+ }
197
+ const [, headerAgentId, tsRaw, nonce, sigB64] = m;
198
+ const ts = Number(tsRaw);
199
+ const now = Date.now();
200
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
201
+ return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
202
+ }
203
+ pruneNonces();
204
+ const nonceKey = `${headerAgentId}:${nonce}`;
205
+ if (nonceSeen.has(nonceKey)) {
206
+ return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
207
+ }
208
+ const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
209
+ if (!agent) {
210
+ return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401, headers: { "Content-Type": "application/json" } });
211
+ }
212
+ try {
213
+ // request.url is just the path portion (e.g. "/Presence")
214
+ const pathname = (request.url ?? "/Presence").split("?")[0];
215
+ const payload = `${headerAgentId}:${tsRaw}:${nonce}:POST:${pathname}`;
216
+ const key = await importEd25519Key(agent.publicKey);
217
+ const sigBuf = b64ToArrayBuffer(sigB64);
218
+ const payloadBuf = new TextEncoder().encode(payload);
219
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, sigBuf, payloadBuf);
220
+ if (!ok) {
221
+ return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
222
+ }
223
+ }
224
+ catch (e) {
225
+ return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
226
+ }
227
+ nonceSeen.set(nonceKey, ts);
228
+ agentId = headerAgentId;
229
+ }
230
+ // ── Validate body ────────────────────────────────────────────────────────
231
+ const { currentTask, activity } = content || {};
232
+ if (activity !== undefined && !VALID_ACTIVITIES.has(activity)) {
233
+ return new Response(JSON.stringify({
234
+ error: "invalid_activity",
235
+ detail: `activity must be one of: ${[...VALID_ACTIVITIES].join(", ")}`,
236
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
237
+ }
238
+ // ── Write presence ───────────────────────────────────────────────────────
239
+ const now = Date.now();
240
+ try {
241
+ let existing = null;
242
+ try {
243
+ existing = await databases.flair.Presence.get(agentId);
244
+ }
245
+ catch { /* first heartbeat */ }
246
+ const record = {
247
+ agentId,
248
+ lastHeartbeatAt: now,
249
+ currentTask: sanitizeCurrentTask(currentTask),
250
+ activity: activity ?? (existing?.activity ?? "idle"),
251
+ };
252
+ if (existing) {
253
+ const merged = { ...existing, ...record };
254
+ await databases.flair.Presence.put(merged);
255
+ }
256
+ else {
257
+ await databases.flair.Presence.put(record);
258
+ }
259
+ return {
260
+ ok: true,
261
+ agentId,
262
+ lastHeartbeatAt: now,
263
+ presenceStatus: "active",
264
+ };
265
+ }
266
+ catch (e) {
267
+ return new Response(JSON.stringify({ error: "presence_write_failed", detail: e?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
268
+ }
269
+ }
270
+ }
@@ -142,8 +142,12 @@ server.http(async (request, nextLayer) => {
142
142
  // and inline JS, with no embedded data. The JS prompts for admin-pass and
143
143
  // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
144
144
  // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
145
- // instances (a localhost caller works only because authorizeLocal=true).
146
- url.pathname === "/ObservationCenter")
145
+ // instances (rockit-local works only because authorizeLocal=true).
146
+ url.pathname === "/ObservationCenter" ||
147
+ // Presence roster is public-safe (field-allowlisted); GET serves the
148
+ // Office Space renderer without auth. POST handles Ed25519 auth internally
149
+ // (allowCreate=true on the Resource).
150
+ url.pathname === "/Presence")
147
151
  return nextLayer(request);
148
152
  // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
149
153
  // trust Harper's auth decision and pass through without requiring additional headers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,4 +1,13 @@
1
1
 
2
+ # ─── Presence table ───────────────────────────────────────────────────────────
3
+
4
+ type Presence @table(database: "flair") @export {
5
+ agentId: ID @primaryKey
6
+ lastHeartbeatAt: BigInt # unix ms timestamp
7
+ currentTask: String # short human string, e.g. "Reviewing flair#467"
8
+ activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
9
+ }
10
+
2
11
  # ─── Observatory tables ───────────────────────────────────────────────────────
3
12
 
4
13
  type ObsOffice @table(database: "flair") @export {