@tpsdev-ai/flair 0.8.3 → 0.10.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.
@@ -110,12 +110,18 @@ async function backfillEmbedding(memoryId) {
110
110
  // ─── HTTP middleware ──────────────────────────────────────────────────────────
111
111
  server.http(async (request, nextLayer) => {
112
112
  const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
113
+ // A2A discovery endpoints: GET returns public agent-card metadata (per
114
+ // A2A spec, cards are intentionally public). POST invokes JSON-RPC
115
+ // actions (message/send writes OrgEvents on behalf of agents,
116
+ // tasks/list reads Beads issues, message/stream subscribes to
117
+ // OrgEvents) — those must be authenticated. Narrowing to GET-only
118
+ // closes the P0 where any caller could forge OrgEvents as any agent
119
+ // and read all internal Beads issues unauthenticated.
120
+ const isA2APath = url.pathname === "/a2a" || url.pathname === "/A2AAdapter" || url.pathname.startsWith("/A2AAdapter/");
113
121
  if (url.pathname === "/health" ||
114
122
  url.pathname === "/Health" ||
115
- url.pathname === "/a2a" ||
116
- url.pathname === "/A2AAdapter" ||
123
+ (request.method === "GET" && isA2APath) ||
117
124
  url.pathname === "/AgentCard" ||
118
- url.pathname.startsWith("/A2AAdapter/") ||
119
125
  url.pathname.startsWith("/AgentCard/") ||
120
126
  // FederationSync uses Ed25519 body-signature auth with anti-replay, validated
121
127
  // by the resource handler (allowCreate=true, same pattern as FederationPair).
@@ -220,6 +226,20 @@ server.http(async (request, nextLayer) => {
220
226
  // ── Ed25519 agent auth ────────────────────────────────────────────────────
221
227
  const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
222
228
  if (!m) {
229
+ // For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
230
+ // the browser shows a native auth dialog instead of a bare 401 page.
231
+ // JSON API endpoints don't get this — they should keep the structured
232
+ // 401 body so the client can parse the error.
233
+ const isAdminPage = url.pathname === "/Admin" || url.pathname.startsWith("/Admin");
234
+ if (isAdminPage) {
235
+ return new Response("Authentication required.", {
236
+ status: 401,
237
+ headers: {
238
+ "WWW-Authenticate": 'Basic realm="Flair Admin"',
239
+ "content-type": "text/plain; charset=utf-8",
240
+ },
241
+ });
242
+ }
223
243
  return new Response(JSON.stringify({ error: "missing_or_invalid_authorization" }), { status: 401 });
224
244
  }
225
245
  const [, agentId, tsRaw, nonce, signatureB64] = m;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure classifier for federation sync records — no Harper imports.
3
+ *
4
+ * Extracted from Federation.ts so the decision can be unit-tested without
5
+ * spinning up Harper's database module. The same SkipReason names are used
6
+ * in SyncLog.skippedReasons so operators can grep for them.
7
+ */
8
+ export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
9
+ if (!knownTables.has(record.table)) {
10
+ return { action: "skip", reason: "unknown_table" };
11
+ }
12
+ const originator = record.originatorInstanceId ?? receiverInstanceId;
13
+ if (originator !== receiverInstanceId && peerRole !== "hub") {
14
+ return { action: "skip", reason: "non_originator" };
15
+ }
16
+ const fiveMinFromNow = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
17
+ if (record.updatedAt > fiveMinFromNow) {
18
+ return { action: "skip", reason: "future_timestamp" };
19
+ }
20
+ const remoteContentHash = record.data?.contentHash;
21
+ if (local &&
22
+ local.contentHash &&
23
+ remoteContentHash &&
24
+ local.contentHash === remoteContentHash &&
25
+ record.updatedAt <= (local.updatedAt ?? "")) {
26
+ return { action: "skip", reason: "no_op_same_hash" };
27
+ }
28
+ return { action: "merge", originator };
29
+ }
@@ -201,13 +201,18 @@ export class HealthDetail extends Resource {
201
201
  for await (const s of db.flair.Soul.search({}))
202
202
  souls.push(s);
203
203
  stats.soulEntries = souls.length;
204
- const byPriority = { critical: 0, high: 0, standard: 0, low: 0 };
204
+ // Soul entries have no severity dimension they are keyed identity facts
205
+ // (role / project / standards / …). A per-priority breakdown was dead
206
+ // telemetry: nothing writes Soul.priority to anything but "standard", and
207
+ // the `?? "standard"` fallback also mislabelled *unset* as *standard*, so
208
+ // it always read 100% standard regardless of the data. Report the honest
209
+ // dimension instead — a count per key.
210
+ const byKey = {};
205
211
  for (const s of souls) {
206
- const p = (s.priority ?? "standard");
207
- if (p in byPriority)
208
- byPriority[p]++;
212
+ const k = (s.key ?? "(unkeyed)");
213
+ byKey[k] = (byKey[k] ?? 0) + 1;
209
214
  }
210
- stats.soul = { total: souls.length, byPriority };
215
+ stats.soul = { total: souls.length, byKey };
211
216
  }
212
217
  catch {
213
218
  stats.soulEntries = null;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Skill content static analyzer — pure, no Harper runtime deps.
3
+ *
4
+ * Imported by `resources/SkillScan.ts` (the HTTP Resource) and by unit
5
+ * tests in `test/unit/SkillScan.test.ts`. Keeping the scanner logic in a
6
+ * separate module lets the tests run without instantiating the Harper
7
+ * runtime.
8
+ *
9
+ * See SkillScan.ts for the design rationale on markdown awareness and the
10
+ * language-agnostic vs shell-only pattern split.
11
+ */
12
+ const SHELL_PATTERNS = [
13
+ { regex: /\bexec\s*\(/, type: "shell_command" },
14
+ { regex: /\bspawn\s*\(/, type: "shell_command" },
15
+ { regex: /\bsystem\s*\(/, type: "shell_command" },
16
+ { regex: /\bchild_process\b/, type: "shell_command" },
17
+ ];
18
+ const NETWORK_PATTERNS = [
19
+ { regex: /\bfetch\s*\(/, type: "network_call" },
20
+ { regex: /\bcurl\b/, type: "network_call" },
21
+ { regex: /https?:\/\//, type: "url_reference" },
22
+ { regex: /\bXMLHttpRequest\b/, type: "network_call" },
23
+ { regex: /\baxios\b/, type: "network_call" },
24
+ ];
25
+ const FS_PATTERNS = [
26
+ { regex: /\bfs\.write/, type: "fs_write" },
27
+ { regex: /\bwriteFile/, type: "fs_write" },
28
+ { regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
29
+ ];
30
+ const ENV_PATTERNS = [
31
+ { regex: /\bprocess\.env\b/, type: "env_access" },
32
+ { regex: /\$ENV\b/, type: "env_access" },
33
+ { regex: /\$\{?\w+\}?/, type: "env_variable" },
34
+ ];
35
+ const ENCODING_PATTERNS = [
36
+ { regex: /\batob\s*\(/, type: "base64_decode" },
37
+ { regex: /\bbtoa\s*\(/, type: "base64_encode" },
38
+ { regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
39
+ { regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
40
+ { regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
41
+ { regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
42
+ ];
43
+ const UNICODE_PATTERNS = [
44
+ { regex: /[​-‏⁠]/, type: "zero_width_char" },
45
+ { regex: /[А-я]/, type: "cyrillic_homoglyph" },
46
+ ];
47
+ const LANG_AGNOSTIC_PATTERNS = [
48
+ ...NETWORK_PATTERNS,
49
+ ...FS_PATTERNS,
50
+ ...ENV_PATTERNS,
51
+ ...ENCODING_PATTERNS,
52
+ ...UNICODE_PATTERNS,
53
+ ];
54
+ const SHELL_FENCE_LANGS = new Set(["", "sh", "bash", "shell", "zsh"]);
55
+ const MARKDOWN_IDENTIFIER_RE = /^[\w@./-]+$/;
56
+ const SHELL_BACKTICK_INDICATORS = [
57
+ /\s/,
58
+ /[|;&]/,
59
+ /\$\(/,
60
+ /\$\{/,
61
+ /^\$\w/,
62
+ />/,
63
+ /<\(/,
64
+ ];
65
+ function lineHasShellishBacktick(line) {
66
+ const matches = line.match(/`([^`\n]+)`/g);
67
+ if (!matches)
68
+ return false;
69
+ for (const raw of matches) {
70
+ const inner = raw.slice(1, -1);
71
+ if (MARKDOWN_IDENTIFIER_RE.test(inner))
72
+ continue;
73
+ for (const indicator of SHELL_BACKTICK_INDICATORS) {
74
+ if (indicator.test(inner))
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+ function assessRisk(violations) {
81
+ if (violations.length === 0)
82
+ return "low";
83
+ const types = new Set(violations.map((v) => v.type));
84
+ // Programmatic shell call patterns: exec/spawn/system/child_process. These
85
+ // are definite payloads when they appear in a skill.
86
+ const hasShellCommand = types.has("shell_command");
87
+ // Backticked shell-ish content in markdown prose. Could be `npm run deploy`
88
+ // in documentation (medium), or could combine with other smells (high).
89
+ const hasShellBacktick = types.has("shell_backtick");
90
+ const hasFs = types.has("fs_write") || types.has("fs_redirect");
91
+ const hasEncoding = types.has("base64_decode") || types.has("hex_decode");
92
+ const hasZeroWidth = types.has("zero_width_char");
93
+ const hasHomoglyph = types.has("cyrillic_homoglyph");
94
+ // Critical: any shell combined with encoded payloads, OR obfuscation chars.
95
+ if (((hasShellCommand || hasShellBacktick) && hasEncoding) ||
96
+ hasZeroWidth ||
97
+ hasHomoglyph) {
98
+ return "critical";
99
+ }
100
+ // High: programmatic shell, fs writes, or shell-backtick + other smells
101
+ // (env access, network call). A skill that quotes a command AND reads env
102
+ // AND fetches a URL is doing something, not just documenting.
103
+ const hasOtherSmells = types.has("env_access") ||
104
+ types.has("env_variable") ||
105
+ types.has("network_call") ||
106
+ types.has("url_reference");
107
+ if (hasShellCommand || hasFs || (hasShellBacktick && hasOtherSmells)) {
108
+ return "high";
109
+ }
110
+ // Medium: shell_backtick alone (doc reference), or network/encoding
111
+ // without shell. Reviewable but not blocked.
112
+ if (hasShellBacktick || hasOtherSmells || hasEncoding) {
113
+ return "medium";
114
+ }
115
+ return "low";
116
+ }
117
+ function detectFenceTransition(line, state) {
118
+ const m = line.match(/^[ \t]*```([\w.-]*)\s*$/);
119
+ if (!m)
120
+ return false;
121
+ if (state.inFence) {
122
+ state.inFence = false;
123
+ state.lang = "";
124
+ }
125
+ else {
126
+ state.inFence = true;
127
+ state.lang = (m[1] || "").toLowerCase();
128
+ }
129
+ return true;
130
+ }
131
+ export function scanSkillContent(content) {
132
+ const lines = content.split("\n");
133
+ const violations = [];
134
+ const fence = { inFence: false, lang: "" };
135
+ const recordIfMatch = (lineIndex, line, patterns) => {
136
+ for (const pattern of patterns) {
137
+ if (pattern.regex.test(line)) {
138
+ violations.push({
139
+ type: pattern.type,
140
+ line: lineIndex + 1,
141
+ content: line.trim().slice(0, 200),
142
+ });
143
+ }
144
+ }
145
+ };
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i] ?? "";
148
+ if (detectFenceTransition(line, fence))
149
+ continue;
150
+ const insideShellFence = fence.inFence && SHELL_FENCE_LANGS.has(fence.lang);
151
+ const insideNonShellFence = fence.inFence && !insideShellFence;
152
+ if (!fence.inFence && lineHasShellishBacktick(line)) {
153
+ violations.push({
154
+ type: "shell_backtick",
155
+ line: i + 1,
156
+ content: line.trim().slice(0, 200),
157
+ });
158
+ }
159
+ if (!insideNonShellFence) {
160
+ recordIfMatch(i, line, SHELL_PATTERNS);
161
+ }
162
+ recordIfMatch(i, line, LANG_AGNOSTIC_PATTERNS);
163
+ }
164
+ const riskLevel = assessRisk(violations);
165
+ return { safe: violations.length === 0, violations, riskLevel };
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.8.3",
3
+ "version": "0.10.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",
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "dist/",
32
32
  "schemas/",
33
+ "templates/",
33
34
  "ui/",
34
35
  "config.yaml",
35
36
  "LICENSE",
@@ -30,7 +30,8 @@ type Peer @table(database: "flair") @export {
30
30
  role: String @indexed # "hub" | "spoke"
31
31
  endpoint: String # wss:// URL for the peer
32
32
  status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
33
- lastSyncAt: String # last successful sync timestamp
33
+ lastSyncAt: String # last contact with this peer (liveness signal)
34
+ lastMergeAt: String # last sync where merged > 0 (data-progress signal)
34
35
  lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
35
36
  relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
36
37
  pairedAt: String
@@ -43,9 +44,11 @@ type SyncLog @table(database: "flair") {
43
44
  id: ID @primaryKey
44
45
  peerId: String! @indexed # which peer this sync was with
45
46
  direction: String! @indexed # "push" | "pull"
46
- recordCount: Int # how many records in this sync batch
47
+ recordCount: Int # how many records merged in this batch
48
+ skippedCount: Int # how many records skipped (sum of skippedReasons)
49
+ skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N }
47
50
  status: String @indexed # "success" | "partial" | "failed"
48
- error: String # error message if failed
51
+ error: String # human-readable summary if partial/failed (includes per-record error messages, capped)
49
52
  durationMs: Int # how long the sync took
50
53
  createdAt: String! @indexed
51
54
  }
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # Flair REM nightly runner shim.
3
+ # Deployed by `flair rem nightly enable` to {{SHIM_PATH}}.
4
+ # Invoked by launchd (macOS) or systemd (Linux) on the configured schedule.
5
+ #
6
+ # Single line that invokes the CLI's run-once subcommand — the runner module
7
+ # does all the work. Logs land in {{HOME}}/.flair/logs/.
8
+ set -e
9
+ exec {{FLAIR_BIN}} rem nightly run-once
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
+ <plist version="1.0">
5
+ <dict>
6
+ <key>Label</key>
7
+ <string>dev.flair.rem.nightly</string>
8
+
9
+ <key>ProgramArguments</key>
10
+ <array>
11
+ <string>{{SHIM_PATH}}</string>
12
+ </array>
13
+
14
+ <key>StartCalendarInterval</key>
15
+ <dict>
16
+ <key>Hour</key>
17
+ <integer>{{HOUR}}</integer>
18
+ <key>Minute</key>
19
+ <integer>{{MINUTE}}</integer>
20
+ </dict>
21
+
22
+ <key>RunAtLoad</key>
23
+ <false/>
24
+
25
+ <key>StandardOutPath</key>
26
+ <string>{{HOME}}/.flair/logs/rem-nightly.stdout.log</string>
27
+
28
+ <key>StandardErrorPath</key>
29
+ <string>{{HOME}}/.flair/logs/rem-nightly.stderr.log</string>
30
+
31
+ <key>WorkingDirectory</key>
32
+ <string>{{HOME}}</string>
33
+
34
+ <key>EnvironmentVariables</key>
35
+ <dict>
36
+ <key>HOME</key>
37
+ <string>{{HOME}}</string>
38
+ <key>PATH</key>
39
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
40
+ <key>FLAIR_AGENT_ID</key>
41
+ <string>{{AGENT_ID}}</string>
42
+ <key>FLAIR_URL</key>
43
+ <string>{{FLAIR_URL}}</string>
44
+ </dict>
45
+ </dict>
46
+ </plist>
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=Flair REM nightly cycle ({{AGENT_ID}})
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ ExecStart={{SHIM_PATH}}
8
+ Environment=FLAIR_AGENT_ID={{AGENT_ID}}
9
+ Environment=FLAIR_URL={{FLAIR_URL}}
10
+ StandardOutput=append:{{HOME}}/.flair/logs/rem-nightly.stdout.log
11
+ StandardError=append:{{HOME}}/.flair/logs/rem-nightly.stderr.log
12
+
13
+ [Install]
14
+ WantedBy=default.target
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Flair REM nightly timer ({{AGENT_ID}})
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* {{HOUR_PAD}}:{{MINUTE_PAD}}:00
6
+ Persistent=true
7
+ Unit=flair-rem-nightly.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target