@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.
Files changed (64) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +246 -0
  3. package/SECURITY.md +116 -0
  4. package/config.yaml +16 -0
  5. package/dist/resources/A2AAdapter.js +474 -0
  6. package/dist/resources/Agent.js +9 -0
  7. package/dist/resources/AgentCard.js +45 -0
  8. package/dist/resources/AgentSeed.js +111 -0
  9. package/dist/resources/IngestEvents.js +149 -0
  10. package/dist/resources/Integration.js +13 -0
  11. package/dist/resources/IssueTokens.js +19 -0
  12. package/dist/resources/Memory.js +122 -0
  13. package/dist/resources/MemoryBootstrap.js +263 -0
  14. package/dist/resources/MemoryConsolidate.js +105 -0
  15. package/dist/resources/MemoryFeed.js +41 -0
  16. package/dist/resources/MemoryReflect.js +105 -0
  17. package/dist/resources/OrgEvent.js +43 -0
  18. package/dist/resources/OrgEventCatchup.js +65 -0
  19. package/dist/resources/OrgEventMaintenance.js +29 -0
  20. package/dist/resources/SemanticSearch.js +147 -0
  21. package/dist/resources/SkillScan.js +101 -0
  22. package/dist/resources/Soul.js +9 -0
  23. package/dist/resources/SoulFeed.js +12 -0
  24. package/dist/resources/WorkspaceLatest.js +45 -0
  25. package/dist/resources/WorkspaceState.js +76 -0
  26. package/dist/resources/auth-middleware.js +470 -0
  27. package/dist/resources/embeddings-provider.js +127 -0
  28. package/dist/resources/embeddings.js +42 -0
  29. package/dist/resources/health.js +6 -0
  30. package/dist/resources/memory-feed-lib.js +15 -0
  31. package/dist/resources/table-helpers.js +35 -0
  32. package/package.json +62 -0
  33. package/resources/A2AAdapter.ts +510 -0
  34. package/resources/Agent.ts +10 -0
  35. package/resources/AgentCard.ts +65 -0
  36. package/resources/AgentSeed.ts +119 -0
  37. package/resources/IngestEvents.ts +189 -0
  38. package/resources/Integration.ts +14 -0
  39. package/resources/IssueTokens.ts +29 -0
  40. package/resources/Memory.ts +138 -0
  41. package/resources/MemoryBootstrap.ts +283 -0
  42. package/resources/MemoryConsolidate.ts +121 -0
  43. package/resources/MemoryFeed.ts +48 -0
  44. package/resources/MemoryReflect.ts +122 -0
  45. package/resources/OrgEvent.ts +63 -0
  46. package/resources/OrgEventCatchup.ts +89 -0
  47. package/resources/OrgEventMaintenance.ts +37 -0
  48. package/resources/SemanticSearch.ts +157 -0
  49. package/resources/SkillScan.ts +146 -0
  50. package/resources/Soul.ts +10 -0
  51. package/resources/SoulFeed.ts +15 -0
  52. package/resources/WorkspaceLatest.ts +66 -0
  53. package/resources/WorkspaceState.ts +102 -0
  54. package/resources/auth-middleware.ts +502 -0
  55. package/resources/embeddings-provider.ts +144 -0
  56. package/resources/embeddings.ts +28 -0
  57. package/resources/health.ts +7 -0
  58. package/resources/memory-feed-lib.ts +22 -0
  59. package/resources/table-helpers.ts +46 -0
  60. package/schemas/agent.graphql +22 -0
  61. package/schemas/event.graphql +12 -0
  62. package/schemas/memory.graphql +50 -0
  63. package/schemas/schema.graphql +41 -0
  64. package/schemas/workspace.graphql +14 -0
@@ -0,0 +1,41 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
3
+ export class FeedMemories extends Resource {
4
+ async post(content) {
5
+ const agentId = String(content?.agentId ?? "");
6
+ const body = String(content?.content ?? "");
7
+ if (!agentId || !body) {
8
+ return new Response(JSON.stringify({ error: "agentId and content are required" }), {
9
+ status: 400,
10
+ headers: { "Content-Type": "application/json" },
11
+ });
12
+ }
13
+ const now = new Date().toISOString();
14
+ const contentHash = computeContentHash(agentId, body);
15
+ const existing = await findExistingMemoryByContentHash(databases.flair.Memory.search(), agentId, contentHash);
16
+ if (existing)
17
+ return existing;
18
+ const record = {
19
+ ...content,
20
+ id: content.id ?? `${agentId}-${Date.now()}`,
21
+ agentId,
22
+ content: body,
23
+ contentHash,
24
+ durability: content.durability ?? "standard",
25
+ createdAt: content.createdAt ?? now,
26
+ updatedAt: content.updatedAt ?? now,
27
+ archived: content.archived ?? false,
28
+ };
29
+ await databases.flair.Memory.put(record);
30
+ return record;
31
+ }
32
+ async *connect(target, incomingMessages) {
33
+ const subscription = await databases.flair.Memory.subscribe(target);
34
+ if (!incomingMessages) {
35
+ return subscription;
36
+ }
37
+ for await (const event of subscription) {
38
+ yield event;
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * POST /MemoryReflect
3
+ *
4
+ * Gathers recent memories for an agent and returns a structured reflection
5
+ * prompt. The agent feeds the prompt + memories to its LLM and writes
6
+ * insights back as persistent memories (with derivedFrom linking).
7
+ *
8
+ * Request:
9
+ * agentId string — which agent to reflect on
10
+ * scope string — "recent" | "tagged" | "all" (default: "recent")
11
+ * since string? — ISO timestamp lower bound (default: 24h ago)
12
+ * maxMemories number? — cap (default: 50)
13
+ * focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" (default: "lessons_learned")
14
+ * tag string? — required when scope="tagged"
15
+ *
16
+ * Response:
17
+ * memories Memory[] — source memories included in the prompt
18
+ * prompt string — structured LLM prompt
19
+ * suggestedTags string[] — tags Flair detected in the source set
20
+ * count number — number of memories included
21
+ */
22
+ import { Resource, databases } from "@harperfast/harper";
23
+ import { isAdmin } from "./auth-middleware.js";
24
+ import { patchRecordSilent } from "./table-helpers.js";
25
+ const FOCUS_PROMPTS = {
26
+ lessons_learned: "Review these memories and identify concrete lessons learned. For each lesson: what happened, what you learned, and how it should change future behavior. Write atomic memories with durability=persistent.",
27
+ patterns: "Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
28
+ decisions: "Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
29
+ errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
30
+ };
31
+ export class ReflectMemories extends Resource {
32
+ async post(data) {
33
+ const { agentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
34
+ if (!agentId)
35
+ return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
36
+ // Auth: agent can only reflect on own memories unless admin
37
+ const actorId = this.request?.tpsAgent;
38
+ if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
39
+ return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
40
+ }
41
+ const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
42
+ const memories = [];
43
+ for await (const record of databases.flair.Memory.search()) {
44
+ if (record.agentId !== agentId)
45
+ continue;
46
+ if (record.archived)
47
+ continue;
48
+ if (record.durability === "permanent")
49
+ continue; // permanent memories don't need reflection
50
+ if (scope === "tagged") {
51
+ if (!tag || !(record.tags ?? []).includes(tag))
52
+ continue;
53
+ }
54
+ else if (scope === "recent") {
55
+ if (!record.createdAt || new Date(record.createdAt) < sinceDate)
56
+ continue;
57
+ }
58
+ // scope="all" passes everything
59
+ const { embedding, ...rest } = record;
60
+ memories.push(rest);
61
+ if (memories.length >= maxMemories)
62
+ break;
63
+ }
64
+ memories.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
65
+ // Collect tags present in source memories
66
+ const tagSet = new Set();
67
+ for (const m of memories) {
68
+ for (const t of m.tags ?? [])
69
+ tagSet.add(t);
70
+ }
71
+ // Build prompt
72
+ const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
73
+ const memorySummary = memories
74
+ .map((m, i) => `[${i + 1}] (${m.id}) ${m.createdAt?.slice(0, 10) ?? "?"}: ${m.content.slice(0, 300)}`)
75
+ .join("\n");
76
+ const prompt = `# Memory Reflection — ${agentId}
77
+ Focus: ${focus}
78
+ Scope: ${scope} (since ${sinceDate.toISOString()})
79
+ Memories: ${memories.length}
80
+
81
+ ## Task
82
+ ${focusText}
83
+
84
+ ## Source Memories
85
+ ${memorySummary || "(none)"}
86
+
87
+ ## Instructions
88
+ For each insight:
89
+ 1. Write a new memory with durability=persistent
90
+ 2. Set derivedFrom=[<source memory ids>]
91
+ 3. Set tags from the source memories where relevant
92
+ 4. Keep each memory atomic — one insight per record`;
93
+ // Update lastReflected on source memories (read-modify-write to preserve embeddings)
94
+ const now = new Date().toISOString();
95
+ for (const m of memories) {
96
+ patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: now });
97
+ }
98
+ return {
99
+ memories,
100
+ prompt,
101
+ suggestedTags: [...tagSet].slice(0, 20),
102
+ count: memories.length,
103
+ };
104
+ }
105
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * OrgEvent.ts — Harper table resource for org-wide activity events.
3
+ *
4
+ * Auth: Ed25519 middleware sets request.tpsAgent.
5
+ * Write: authorId must match authenticated agent (or admin).
6
+ * Read: any authenticated participant can read (org-scoped).
7
+ */
8
+ import { databases } from "@harperfast/harper";
9
+ export class OrgEvent extends databases.flair.OrgEvent {
10
+ async post(content, context) {
11
+ const agentId = context?.request?.tpsAgent;
12
+ // authorId must match authenticated agent (unless admin)
13
+ if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
14
+ return new Response(JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
15
+ }
16
+ // Generate composite ID if not provided
17
+ if (!content.id) {
18
+ content.id = `${content.authorId}-${Date.now()}`;
19
+ }
20
+ content.createdAt = new Date().toISOString();
21
+ // Harper 5: table resources use put() for create/upsert (post() removed)
22
+ return databases.flair.OrgEvent.put(content);
23
+ }
24
+ async put(content, context) {
25
+ const agentId = context?.request?.tpsAgent;
26
+ if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
27
+ return new Response(JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
28
+ }
29
+ return databases.flair.OrgEvent.put(content);
30
+ }
31
+ async delete(id, context) {
32
+ const agentId = context?.request?.tpsAgent;
33
+ if (!agentId)
34
+ return super.delete(id, context);
35
+ const record = await this.get(id);
36
+ if (!record)
37
+ return super.delete(id, context);
38
+ if (!context?.request?.tpsAgentIsAdmin && record.authorId !== agentId) {
39
+ return new Response(JSON.stringify({ error: "forbidden: cannot delete events authored by another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
40
+ }
41
+ return super.delete(id, context);
42
+ }
43
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * OrgEventCatchup.ts — Custom resource for filtered event retrieval.
3
+ *
4
+ * GET /OrgEventCatchup/{participantId}?since=<ISO timestamp>
5
+ *
6
+ * Returns events where:
7
+ * - targetIds includes participantId OR targetIds is empty/null
8
+ * - createdAt >= since
9
+ * Sorted by createdAt ascending (oldest first for in-order processing).
10
+ * Limit 50 events max.
11
+ */
12
+ import { Resource, databases } from "@harperfast/harper";
13
+ export class OrgEventCatchup extends Resource {
14
+ // HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
15
+ async get(pathInfo) {
16
+ const request = this.request;
17
+ const callerAgent = request?.tpsAgent;
18
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true;
19
+ // Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
20
+ const participantId = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
21
+ (typeof pathInfo === "string" ? pathInfo : null) ??
22
+ this.getId?.() ??
23
+ null;
24
+ if (!participantId) {
25
+ return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
26
+ }
27
+ // Auth: requesting agent must match participantId (or admin)
28
+ if (callerAgent && !callerIsAdmin && callerAgent !== participantId) {
29
+ return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
30
+ }
31
+ // Harper parses query params into pathInfo.conditions array:
32
+ // e.g. ?since=value → conditions: [{attribute:"since", value:"...", comparator:"equals"}]
33
+ // pathInfo.id is the URL path segment (participantId).
34
+ const since = (typeof pathInfo === "object" && pathInfo !== null
35
+ ? pathInfo.conditions?.find((c) => c.attribute === "since")?.value ?? null
36
+ : null);
37
+ if (!since) {
38
+ return new Response(JSON.stringify({ error: "since query parameter required (ISO timestamp)" }), { status: 400, headers: { "Content-Type": "application/json" } });
39
+ }
40
+ const sinceDate = new Date(since);
41
+ if (isNaN(sinceDate.getTime())) {
42
+ return new Response(JSON.stringify({ error: "invalid since timestamp" }), { status: 400, headers: { "Content-Type": "application/json" } });
43
+ }
44
+ // Query all OrgEvents and filter in-memory
45
+ const results = [];
46
+ for await (const event of databases.flair.OrgEvent.search()) {
47
+ // Filter by createdAt >= since
48
+ if (!event.createdAt || event.createdAt < since)
49
+ continue;
50
+ // Filter by targetIds: includes participantId OR empty/null
51
+ const targets = event.targetIds;
52
+ const isTargeted = !targets || targets.length === 0 || targets.includes(participantId);
53
+ if (!isTargeted)
54
+ continue;
55
+ // Skip expired events
56
+ if (event.expiresAt && new Date(event.expiresAt) < new Date())
57
+ continue;
58
+ results.push(event);
59
+ }
60
+ // Sort ascending by createdAt (oldest first)
61
+ results.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
62
+ // Limit to 50
63
+ return results.slice(0, 50);
64
+ }
65
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * OrgEventMaintenance.ts — Maintenance worker for expired OrgEvents.
3
+ *
4
+ * POST /OrgEventMaintenance/ — deletes all records where expiresAt < now.
5
+ * Auth: admin only.
6
+ */
7
+ import { Resource, databases } from "@harperfast/harper";
8
+ export class OrgEventMaintenance extends Resource {
9
+ async post(_data, context) {
10
+ // Admin-only
11
+ if (!context?.request?.tpsAgentIsAdmin) {
12
+ return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403, headers: { "Content-Type": "application/json" } });
13
+ }
14
+ const now = new Date().toISOString();
15
+ const toDelete = [];
16
+ for await (const event of databases.flair.OrgEvent.search()) {
17
+ if (event.expiresAt && event.expiresAt < now) {
18
+ toDelete.push(event.id);
19
+ }
20
+ }
21
+ for (const id of toDelete) {
22
+ try {
23
+ await databases.flair.OrgEvent.delete(id);
24
+ }
25
+ catch { }
26
+ }
27
+ return { deleted: toDelete.length };
28
+ }
29
+ }
@@ -0,0 +1,147 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { getEmbedding, getMode } from "./embeddings-provider.js";
3
+ import { patchRecord } from "./table-helpers.js";
4
+ function cosineSimilarity(a, b) {
5
+ let dot = 0;
6
+ const len = Math.min(a.length, b.length);
7
+ for (let i = 0; i < len; i++)
8
+ dot += a[i] * b[i];
9
+ return dot;
10
+ }
11
+ // ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
12
+ const DURABILITY_WEIGHTS = {
13
+ permanent: 1.0,
14
+ persistent: 0.9,
15
+ standard: 0.7,
16
+ ephemeral: 0.4,
17
+ };
18
+ // Half-life in days for exponential decay per durability level
19
+ const DECAY_HALF_LIFE_DAYS = {
20
+ permanent: Infinity, // never decays
21
+ persistent: 90,
22
+ standard: 30,
23
+ ephemeral: 7,
24
+ };
25
+ function recencyFactor(createdAt, durability) {
26
+ const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
27
+ if (halfLife === Infinity)
28
+ return 1.0;
29
+ const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
30
+ const lambda = Math.LN2 / halfLife;
31
+ return Math.exp(-lambda * ageDays);
32
+ }
33
+ function retrievalBoost(retrievalCount) {
34
+ if (!retrievalCount || retrievalCount <= 0)
35
+ return 1.0;
36
+ return 1.0 + 0.1 * Math.log2(retrievalCount); // gentle boost: 10 retrievals → ~1.33x
37
+ }
38
+ function compositeScore(semanticScore, record) {
39
+ const durability = record.durability ?? "standard";
40
+ const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
41
+ const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
42
+ const rBoost = retrievalBoost(record.retrievalCount ?? 0);
43
+ return semanticScore * dWeight * rFactor * rBoost;
44
+ }
45
+ export class SemanticSearch extends Resource {
46
+ async post(data) {
47
+ const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite" } = data || {};
48
+ const subjectFilter = subjects
49
+ ? new Set(subjects.map((s) => s.toLowerCase()))
50
+ : subject
51
+ ? new Set([subject.toLowerCase()])
52
+ : null;
53
+ // Defense-in-depth: verify agentId matches authenticated agent.
54
+ // The middleware already enforces this for non-admins, but double-check here
55
+ // so direct Harper API calls (e.g., admin scripts) are also scoped correctly.
56
+ const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
57
+ const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
58
+ if (authenticatedAgent && !callerIsAdmin && agentId && agentId !== authenticatedAgent) {
59
+ return new Response(JSON.stringify({
60
+ error: "forbidden: agentId must match authenticated agent",
61
+ }), { status: 403, headers: { "Content-Type": "application/json" } });
62
+ }
63
+ // Determine searchable agent IDs (own + granted)
64
+ const searchAgentIds = new Set();
65
+ if (agentId)
66
+ searchAgentIds.add(agentId);
67
+ if (agentId) {
68
+ try {
69
+ for await (const grant of databases.flair.MemoryGrant.search({
70
+ conditions: [{ attribute: "granteeId", comparator: "equals", value: agentId }],
71
+ })) {
72
+ if (grant.scope === "search" || grant.scope === "read") {
73
+ searchAgentIds.add(grant.ownerId);
74
+ }
75
+ }
76
+ }
77
+ catch { /* MemoryGrant may not exist */ }
78
+ }
79
+ // Generate query embedding
80
+ let qEmb = queryEmbedding;
81
+ if (!qEmb && q) {
82
+ if (getMode() !== "none") {
83
+ try {
84
+ qEmb = await getEmbedding(String(q).slice(0, 500));
85
+ }
86
+ catch { }
87
+ }
88
+ }
89
+ const results = [];
90
+ // Iterate ALL memories, filter by agent ID set
91
+ for await (const record of databases.flair.Memory.search()) {
92
+ // Filter by agent
93
+ if (searchAgentIds.size > 0 && !searchAgentIds.has(record.agentId)) {
94
+ if (record.visibility !== "office")
95
+ continue;
96
+ }
97
+ if (record.archived === true)
98
+ continue; // soft-deleted — excluded from search by default
99
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
100
+ continue;
101
+ if (tag && !(record.tags || []).includes(tag))
102
+ continue;
103
+ if (subjectFilter && record.subject && !subjectFilter.has(String(record.subject).toLowerCase()))
104
+ continue;
105
+ let rawScore = 0;
106
+ if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
107
+ rawScore += 0.5;
108
+ }
109
+ if (qEmb && record.embedding && qEmb.length === record.embedding.length) {
110
+ rawScore += cosineSimilarity(qEmb, record.embedding);
111
+ }
112
+ if (q && rawScore === 0)
113
+ continue;
114
+ // Apply composite scoring (temporal decay + durability + retrieval boost)
115
+ const finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
116
+ const { embedding, ...rest } = record;
117
+ results.push({
118
+ ...rest,
119
+ _score: Math.round(finalScore * 1000) / 1000,
120
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
121
+ _source: record.agentId !== agentId ? record.agentId : undefined,
122
+ });
123
+ }
124
+ // Build superseded set and filter (unless caller opts in to see full history)
125
+ let filteredResults = results;
126
+ if (!includeSuperseded) {
127
+ const supersededIds = new Set();
128
+ for (const r of results) {
129
+ if (r.supersedes)
130
+ supersededIds.add(r.supersedes);
131
+ }
132
+ filteredResults = results.filter((r) => !supersededIds.has(r.id));
133
+ }
134
+ filteredResults.sort((a, b) => b._score - a._score);
135
+ const topResults = filteredResults.slice(0, limit);
136
+ // Async hit tracking — don't block the response
137
+ // Use patchRecord to avoid wiping other fields (embedding, content, etc.)
138
+ const now = new Date().toISOString();
139
+ for (const r of topResults) {
140
+ patchRecord(databases.flair.Memory, r.id, {
141
+ retrievalCount: (r.retrievalCount || 0) + 1,
142
+ lastRetrieved: now,
143
+ }).catch(() => { });
144
+ }
145
+ return { results: topResults };
146
+ }
147
+ }
@@ -0,0 +1,101 @@
1
+ import { Resource } from "@harperfast/harper";
2
+ const SHELL_PATTERNS = [
3
+ { regex: /\bexec\s*\(/, type: "shell_command" },
4
+ { regex: /\bspawn\s*\(/, type: "shell_command" },
5
+ { regex: /\bsystem\s*\(/, type: "shell_command" },
6
+ { regex: /`[^`]*`/, type: "shell_backtick" },
7
+ { regex: /\bchild_process\b/, type: "shell_command" },
8
+ ];
9
+ const NETWORK_PATTERNS = [
10
+ { regex: /\bfetch\s*\(/, type: "network_call" },
11
+ { regex: /\bcurl\b/, type: "network_call" },
12
+ { regex: /https?:\/\//, type: "url_reference" },
13
+ { regex: /\bXMLHttpRequest\b/, type: "network_call" },
14
+ { regex: /\baxios\b/, type: "network_call" },
15
+ ];
16
+ const FS_PATTERNS = [
17
+ { regex: /\bfs\.write/, type: "fs_write" },
18
+ { regex: /\bwriteFile/, type: "fs_write" },
19
+ { regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
20
+ ];
21
+ const ENV_PATTERNS = [
22
+ { regex: /\bprocess\.env\b/, type: "env_access" },
23
+ { regex: /\$ENV\b/, type: "env_access" },
24
+ { regex: /\$\{?\w+\}?/, type: "env_variable" },
25
+ ];
26
+ const ENCODING_PATTERNS = [
27
+ { regex: /\batob\s*\(/, type: "base64_decode" },
28
+ { regex: /\bbtoa\s*\(/, type: "base64_encode" },
29
+ { regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
30
+ { regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
31
+ { regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
32
+ { regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
33
+ ];
34
+ // Unicode zero-width and homoglyph detection (raw chars)
35
+ const UNICODE_PATTERNS = [
36
+ { regex: /[\u200B-\u200F\u2060\uFEFF]/, type: "zero_width_char" },
37
+ { regex: /[\u0410-\u044F]/, type: "cyrillic_homoglyph" }, // Cyrillic chars that look like Latin
38
+ ];
39
+ const ALL_PATTERNS = [
40
+ ...SHELL_PATTERNS,
41
+ ...NETWORK_PATTERNS,
42
+ ...FS_PATTERNS,
43
+ ...ENV_PATTERNS,
44
+ ...ENCODING_PATTERNS,
45
+ ...UNICODE_PATTERNS,
46
+ ];
47
+ function assessRisk(violations) {
48
+ if (violations.length === 0)
49
+ return "low";
50
+ const types = new Set(violations.map((v) => v.type));
51
+ const hasShell = types.has("shell_command") || types.has("shell_backtick");
52
+ const hasNetwork = types.has("network_call");
53
+ const hasFs = types.has("fs_write") || types.has("fs_redirect");
54
+ const hasZeroWidth = types.has("zero_width_char");
55
+ const hasHomoglyph = types.has("cyrillic_homoglyph");
56
+ // Critical: shell + encoding, or zero-width/homoglyph obfuscation
57
+ if ((hasShell && (types.has("base64_decode") || types.has("hex_decode"))) ||
58
+ hasZeroWidth || hasHomoglyph) {
59
+ return "critical";
60
+ }
61
+ // High: direct shell commands or fs writes
62
+ if (hasShell || hasFs)
63
+ return "high";
64
+ // Medium: network calls or encoding without shell
65
+ if (hasNetwork || types.has("base64_decode") || types.has("hex_decode"))
66
+ return "medium";
67
+ return "low";
68
+ }
69
+ export class SkillScan extends Resource {
70
+ async post(data, context) {
71
+ const { content } = data || {};
72
+ if (!content || typeof content !== "string") {
73
+ return new Response(JSON.stringify({ error: "content (string) required" }), { status: 400, headers: { "Content-Type": "application/json" } });
74
+ }
75
+ // 8KB size limit
76
+ const byteLength = new TextEncoder().encode(content).length;
77
+ if (byteLength > 8192) {
78
+ return new Response(JSON.stringify({ error: `Content exceeds 8KB limit (${byteLength} bytes)` }), { status: 413, headers: { "Content-Type": "application/json" } });
79
+ }
80
+ const lines = content.split("\n");
81
+ const violations = [];
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ for (const pattern of ALL_PATTERNS) {
85
+ if (pattern.regex.test(line)) {
86
+ violations.push({
87
+ type: pattern.type,
88
+ line: i + 1,
89
+ content: line.trim().slice(0, 200),
90
+ });
91
+ }
92
+ }
93
+ }
94
+ const riskLevel = assessRisk(violations);
95
+ return {
96
+ safe: violations.length === 0,
97
+ violations,
98
+ riskLevel,
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,9 @@
1
+ import { databases } from "@harperfast/harper";
2
+ export class Soul extends databases.flair.Soul {
3
+ async post(content, context) {
4
+ content.durability ||= "permanent";
5
+ content.createdAt = new Date().toISOString();
6
+ content.updatedAt = content.createdAt;
7
+ return super.post(content, context);
8
+ }
9
+ }
@@ -0,0 +1,12 @@
1
+ import { Resource, databases } from '@harperfast/harper';
2
+ export class FeedSouls extends Resource {
3
+ async *connect(target, incomingMessages) {
4
+ const subscription = await databases.flair.Soul.subscribe(target);
5
+ if (!incomingMessages) {
6
+ return subscription;
7
+ }
8
+ for await (const event of subscription) {
9
+ yield event;
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * WorkspaceLatest.ts — Custom resource returning the most recent WorkspaceState for an agent.
3
+ *
4
+ * GET /WorkspaceLatest/{agentId} — returns most recent WorkspaceState record.
5
+ * Auth: requesting agent must match agentId in path (or be admin).
6
+ */
7
+ import { Resource, databases } from "@harperfast/harper";
8
+ export class WorkspaceLatest extends Resource {
9
+ async get(pathInfo) {
10
+ const request = this.context?.request ?? this.request;
11
+ const callerAgent = request?.tpsAgent;
12
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true;
13
+ // Extract agentId from path: /WorkspaceLatest/{agentId}
14
+ const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
15
+ this.getId?.() ??
16
+ null;
17
+ if (!agentId) {
18
+ return new Response(JSON.stringify({ error: "agentId required in path: GET /WorkspaceLatest/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
19
+ }
20
+ // Auth: requesting agent must match path agentId (or admin)
21
+ if (callerAgent && !callerIsAdmin && callerAgent !== agentId) {
22
+ return new Response(JSON.stringify({ error: "forbidden: cannot read workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
23
+ }
24
+ // Query WorkspaceState table for this agent, sorted by timestamp desc
25
+ let latest = null;
26
+ try {
27
+ const results = databases.flair.WorkspaceState.search({
28
+ conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
29
+ sort: { attribute: "timestamp", descending: true },
30
+ limit: 1,
31
+ });
32
+ for await (const row of results) {
33
+ latest = row;
34
+ break;
35
+ }
36
+ }
37
+ catch (err) {
38
+ return new Response(JSON.stringify({ error: "workspace_state_query_failed", detail: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
39
+ }
40
+ if (!latest) {
41
+ return new Response(JSON.stringify({ error: "no_workspace_state_found", agentId }), { status: 404, headers: { "Content-Type": "application/json" } });
42
+ }
43
+ return latest;
44
+ }
45
+ }