@versdotsh/reef 0.1.2

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 (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Feed HTTP routes — event publishing, listing, SSE streaming.
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { streamSSE } from "hono/streaming";
7
+ import type { FeedStore, PublishInput, FeedEvent } from "./store.js";
8
+ import { VALID_EVENT_TYPES } from "./store.js";
9
+
10
+ export function createRoutes(store: FeedStore): Hono {
11
+ const routes = new Hono();
12
+
13
+ // Publish an event
14
+ routes.post("/events", async (c) => {
15
+ let body: unknown;
16
+ try {
17
+ body = await c.req.json();
18
+ } catch {
19
+ return c.json({ error: "Invalid JSON body" }, 400);
20
+ }
21
+
22
+ const input = body as Record<string, unknown>;
23
+ if (!input.agent || typeof input.agent !== "string") {
24
+ return c.json({ error: "Missing or invalid 'agent' field" }, 400);
25
+ }
26
+ if (!input.type || !VALID_EVENT_TYPES.has(input.type as string)) {
27
+ return c.json(
28
+ { error: `Invalid 'type'. Must be one of: ${[...VALID_EVENT_TYPES].join(", ")}` },
29
+ 400,
30
+ );
31
+ }
32
+ if (!input.summary || typeof input.summary !== "string") {
33
+ return c.json({ error: "Missing or invalid 'summary' field" }, 400);
34
+ }
35
+
36
+ const event = store.publish({
37
+ agent: input.agent as string,
38
+ type: input.type as PublishInput["type"],
39
+ summary: input.summary as string,
40
+ detail: input.detail as string | undefined,
41
+ metadata: input.metadata as Record<string, unknown> | undefined,
42
+ });
43
+ return c.json(event, 201);
44
+ });
45
+
46
+ // List events
47
+ routes.get("/events", (c) => {
48
+ const agent = c.req.query("agent");
49
+ const type = c.req.query("type");
50
+ const since = c.req.query("since");
51
+ const limitStr = c.req.query("limit");
52
+ const limit = limitStr ? parseInt(limitStr, 10) : 50;
53
+
54
+ const events = store.list({ agent: agent || undefined, type: type || undefined, since: since || undefined, limit });
55
+ return c.json(events);
56
+ });
57
+
58
+ // Get a single event
59
+ routes.get("/events/:id", (c) => {
60
+ const event = store.get(c.req.param("id"));
61
+ if (!event) return c.json({ error: "Event not found" }, 404);
62
+ return c.json(event);
63
+ });
64
+
65
+ // Clear all events
66
+ routes.delete("/events", (c) => {
67
+ store.clear();
68
+ return c.json({ ok: true });
69
+ });
70
+
71
+ // Stats
72
+ routes.get("/stats", (c) => c.json(store.stats()));
73
+
74
+ // SSE stream
75
+ routes.get("/stream", (c) => {
76
+ const agent = c.req.query("agent") || undefined;
77
+ const sinceId = c.req.query("since");
78
+
79
+ return streamSSE(c, async (stream) => {
80
+ // Replay missed events on reconnect
81
+ if (sinceId) {
82
+ const missed = store.eventsSince(sinceId, agent);
83
+ for (const event of missed) {
84
+ await stream.writeSSE({ data: JSON.stringify(event) });
85
+ }
86
+ }
87
+
88
+ const unsubscribe = store.subscribe((event: FeedEvent) => {
89
+ if (agent && event.agent !== agent) return;
90
+ stream.writeSSE({ data: JSON.stringify(event) }).catch(() => {});
91
+ });
92
+
93
+ const heartbeat = setInterval(() => {
94
+ stream.write(": heartbeat\n\n").catch(() => {});
95
+ }, 15000);
96
+
97
+ stream.onAbort(() => {
98
+ unsubscribe();
99
+ clearInterval(heartbeat);
100
+ });
101
+
102
+ await new Promise<void>((resolve) => {
103
+ stream.onAbort(() => resolve());
104
+ });
105
+
106
+ unsubscribe();
107
+ clearInterval(heartbeat);
108
+ });
109
+ });
110
+
111
+ // ─── UI Panel ───
112
+
113
+ routes.get("/_panel", (c) => {
114
+ return c.html(`
115
+ <style>
116
+ .panel-feed { height: 100%; overflow-y: auto; }
117
+ .panel-feed .event {
118
+ padding: 8px 16px; border-bottom: 1px solid var(--border, #2a2a2a);
119
+ font-size: 12px; animation: feedFadeIn 0.2s;
120
+ }
121
+ @keyframes feedFadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; } }
122
+ .panel-feed .ev-header { display: flex; gap: 8px; align-items: center; margin-bottom: 2px; }
123
+ .panel-feed .ev-agent { color: var(--purple, #a7f); font-weight: 500; }
124
+ .panel-feed .ev-type {
125
+ font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600;
126
+ background: #333; color: var(--text, #ccc);
127
+ }
128
+ .panel-feed .ev-time { color: var(--text-dim, #666); font-size: 10px; margin-left: auto; }
129
+ .panel-feed .ev-summary { color: var(--text, #ccc); }
130
+ .panel-feed .empty { color: var(--text-dim, #666); font-style: italic; padding: 20px; text-align: center; }
131
+ .panel-feed .live-dot {
132
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
133
+ background: var(--accent, #4f9); margin-right: 6px; animation: feedPulse 2s infinite;
134
+ }
135
+ @keyframes feedPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
136
+ .panel-feed .feed-header {
137
+ padding: 8px 16px; border-bottom: 1px solid var(--border, #2a2a2a);
138
+ font-size: 11px; color: var(--text-dim, #666); background: var(--bg-panel, #111);
139
+ position: sticky; top: 0; z-index: 1;
140
+ }
141
+ </style>
142
+
143
+ <div class="panel-feed" id="feed-root">
144
+ <div class="feed-header"><span class="live-dot"></span>Live feed</div>
145
+ <div id="feed-events"><div class="empty">Loading…</div></div>
146
+ </div>
147
+
148
+ <script>
149
+ (function() {
150
+ const container = document.getElementById('feed-events');
151
+ const API = typeof PANEL_API !== 'undefined' ? PANEL_API : '/ui/api';
152
+
153
+ function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
154
+ function ago(iso) {
155
+ const ms = Date.now() - new Date(iso).getTime();
156
+ if (ms < 60000) return Math.floor(ms/1000) + 's ago';
157
+ if (ms < 3600000) return Math.floor(ms/60000) + 'm ago';
158
+ return Math.floor(ms/3600000) + 'h ago';
159
+ }
160
+
161
+ function renderEvent(evt) {
162
+ const el = document.createElement('div');
163
+ el.className = 'event';
164
+ el.innerHTML = '<div class="ev-header">'
165
+ + '<span class="ev-agent">' + esc(evt.agent) + '</span>'
166
+ + '<span class="ev-type">' + esc(evt.type) + '</span>'
167
+ + '<span class="ev-time">' + (evt.timestamp ? ago(evt.timestamp) : '') + '</span>'
168
+ + '</div><div class="ev-summary">' + esc(evt.summary) + '</div>';
169
+ return el;
170
+ }
171
+
172
+ async function loadInitial() {
173
+ try {
174
+ const res = await fetch(API + '/feed/events?limit=100');
175
+ if (!res.ok) throw new Error(res.status);
176
+ const data = await res.json();
177
+ const events = Array.isArray(data) ? data : (data.events || []);
178
+ events.reverse();
179
+ container.innerHTML = '';
180
+ events.forEach(e => container.appendChild(renderEvent(e)));
181
+ if (!events.length) container.innerHTML = '<div class="empty">No events yet</div>';
182
+ } catch (e) {
183
+ container.innerHTML = '<div class="empty">Feed unavailable: ' + esc(e.message) + '</div>';
184
+ }
185
+ }
186
+
187
+ // SSE for live updates
188
+ function startSSE() {
189
+ fetch(API + '/feed/stream').then(res => {
190
+ if (!res.ok) return;
191
+ const reader = res.body.getReader();
192
+ const dec = new TextDecoder();
193
+ let buf = '';
194
+ (async function read() {
195
+ while (true) {
196
+ const { done, value } = await reader.read();
197
+ if (done) break;
198
+ buf += dec.decode(value, { stream: true });
199
+ const lines = buf.split('\\n');
200
+ buf = lines.pop() || '';
201
+ for (const line of lines) {
202
+ if (line.startsWith('data: ')) {
203
+ try {
204
+ const evt = JSON.parse(line.slice(6));
205
+ const empty = container.querySelector('.empty');
206
+ if (empty) empty.remove();
207
+ container.prepend(renderEvent(evt));
208
+ } catch {}
209
+ }
210
+ }
211
+ }
212
+ })().catch(() => setTimeout(startSSE, 5000));
213
+ }).catch(() => setTimeout(startSSE, 5000));
214
+ }
215
+
216
+ loadInitial();
217
+ startSSE();
218
+ })();
219
+ </script>
220
+ `);
221
+ });
222
+
223
+ return routes;
224
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Feed store — append-only event stream with in-memory index and pub/sub.
3
+ */
4
+
5
+ import { ulid } from "ulid";
6
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
9
+ // =============================================================================
10
+ // Types
11
+ // =============================================================================
12
+
13
+ export type FeedEventType =
14
+ | "task_started"
15
+ | "task_completed"
16
+ | "task_failed"
17
+ | "blocker_found"
18
+ | "question"
19
+ | "finding"
20
+ | "skill_proposed"
21
+ | "file_changed"
22
+ | "cost_update"
23
+ | "agent_started"
24
+ | "agent_stopped"
25
+ | "token_update"
26
+ | "custom";
27
+
28
+ export const VALID_EVENT_TYPES = new Set<string>([
29
+ "task_started",
30
+ "task_completed",
31
+ "task_failed",
32
+ "blocker_found",
33
+ "question",
34
+ "finding",
35
+ "skill_proposed",
36
+ "file_changed",
37
+ "cost_update",
38
+ "agent_started",
39
+ "agent_stopped",
40
+ "token_update",
41
+ "custom",
42
+ ]);
43
+
44
+ export interface FeedEvent {
45
+ id: string;
46
+ agent: string;
47
+ type: FeedEventType;
48
+ summary: string;
49
+ detail?: string;
50
+ metadata?: Record<string, unknown>;
51
+ timestamp: string;
52
+ }
53
+
54
+ export interface PublishInput {
55
+ agent: string;
56
+ type: FeedEventType;
57
+ summary: string;
58
+ detail?: string;
59
+ metadata?: Record<string, unknown>;
60
+ }
61
+
62
+ export interface FilterOptions {
63
+ agent?: string;
64
+ type?: string;
65
+ since?: string;
66
+ limit?: number;
67
+ }
68
+
69
+ type Subscriber = (event: FeedEvent) => void;
70
+
71
+ // =============================================================================
72
+ // Store
73
+ // =============================================================================
74
+
75
+ export class FeedStore {
76
+ private events: FeedEvent[] = [];
77
+ private subscribers = new Set<Subscriber>();
78
+ private filePath: string;
79
+ private maxInMemory: number;
80
+
81
+ constructor(filePath = "data/feed.jsonl", maxInMemory = 10000) {
82
+ this.filePath = filePath;
83
+ this.maxInMemory = maxInMemory;
84
+ this.load();
85
+ }
86
+
87
+ private load(): void {
88
+ if (!existsSync(this.filePath)) return;
89
+ const content = readFileSync(this.filePath, "utf-8").trim();
90
+ if (!content) return;
91
+
92
+ for (const line of content.split("\n")) {
93
+ if (!line.trim()) continue;
94
+ try {
95
+ this.events.push(JSON.parse(line));
96
+ } catch {
97
+ // skip malformed
98
+ }
99
+ }
100
+
101
+ if (this.events.length > this.maxInMemory) {
102
+ this.events = this.events.slice(-this.maxInMemory);
103
+ }
104
+ }
105
+
106
+ publish(input: PublishInput): FeedEvent {
107
+ const event: FeedEvent = {
108
+ id: ulid(),
109
+ agent: input.agent,
110
+ type: input.type,
111
+ summary: input.summary,
112
+ timestamp: new Date().toISOString(),
113
+ };
114
+ if (input.detail !== undefined) event.detail = input.detail;
115
+ if (input.metadata !== undefined) event.metadata = input.metadata;
116
+
117
+ const dir = dirname(this.filePath);
118
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
119
+ appendFileSync(this.filePath, JSON.stringify(event) + "\n");
120
+
121
+ this.events.push(event);
122
+ if (this.events.length > this.maxInMemory) {
123
+ this.events = this.events.slice(-this.maxInMemory);
124
+ }
125
+
126
+ for (const sub of this.subscribers) {
127
+ try { sub(event); } catch { /* ignore */ }
128
+ }
129
+
130
+ return event;
131
+ }
132
+
133
+ get(id: string): FeedEvent | undefined {
134
+ return this.events.find((e) => e.id === id);
135
+ }
136
+
137
+ list(opts: FilterOptions = {}): FeedEvent[] {
138
+ let result = this.events;
139
+
140
+ if (opts.agent) result = result.filter((e) => e.agent === opts.agent);
141
+ if (opts.type) result = result.filter((e) => e.type === opts.type);
142
+
143
+ if (opts.since) {
144
+ const since = opts.since;
145
+ if (/^[0-9A-Z]{26}$/i.test(since)) {
146
+ result = result.filter((e) => e.id > since);
147
+ } else {
148
+ const sinceTime = new Date(since).getTime();
149
+ result = result.filter((e) => new Date(e.timestamp).getTime() > sinceTime);
150
+ }
151
+ }
152
+
153
+ const limit = opts.limit ?? 50;
154
+ if (limit > 0) result = result.slice(-limit);
155
+
156
+ return result;
157
+ }
158
+
159
+ subscribe(fn: Subscriber): () => void {
160
+ this.subscribers.add(fn);
161
+ return () => { this.subscribers.delete(fn); };
162
+ }
163
+
164
+ eventsSince(sinceId: string, agent?: string): FeedEvent[] {
165
+ let result = this.events.filter((e) => e.id > sinceId);
166
+ if (agent) result = result.filter((e) => e.agent === agent);
167
+ return result;
168
+ }
169
+
170
+ stats() {
171
+ const byAgent: Record<string, number> = {};
172
+ const byType: Record<string, number> = {};
173
+ const latestPerAgent: Record<string, FeedEvent> = {};
174
+
175
+ for (const event of this.events) {
176
+ byAgent[event.agent] = (byAgent[event.agent] || 0) + 1;
177
+ byType[event.type] = (byType[event.type] || 0) + 1;
178
+ latestPerAgent[event.agent] = event;
179
+ }
180
+
181
+ return { total: this.events.length, byAgent, byType, latestPerAgent };
182
+ }
183
+
184
+ clear(): void {
185
+ this.events = [];
186
+ const dir = dirname(this.filePath);
187
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
188
+ writeFileSync(this.filePath, "");
189
+ }
190
+
191
+ get size(): number {
192
+ return this.events.length;
193
+ }
194
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Feed tools — publish events, list events, get stats.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { FleetClient } from "../src/core/types.js";
7
+ import { Type } from "@sinclair/typebox";
8
+ import { StringEnum } from "@mariozechner/pi-ai";
9
+
10
+ export function registerTools(pi: ExtensionAPI, client: FleetClient) {
11
+ pi.registerTool({
12
+ name: "feed_publish",
13
+ label: "Feed: Publish Event",
14
+ description:
15
+ "Publish an event to the activity feed. Used for coordination, progress reporting, and audit trails.",
16
+ parameters: Type.Object({
17
+ type: StringEnum(
18
+ [
19
+ "task_started", "task_completed", "task_failed",
20
+ "blocker_found", "question", "finding",
21
+ "skill_proposed", "file_changed", "cost_update",
22
+ "agent_started", "agent_stopped", "custom",
23
+ ] as const,
24
+ { description: "Event type" },
25
+ ),
26
+ summary: Type.String({ description: "Short human-readable summary" }),
27
+ detail: Type.Optional(Type.String({ description: "Longer detail or structured data" })),
28
+ }),
29
+ async execute(_id, params) {
30
+ if (!client.getBaseUrl()) return client.noUrl();
31
+ try {
32
+ const event = await client.api("POST", "/feed/events", {
33
+ ...params,
34
+ agent: client.agentName,
35
+ });
36
+ return client.ok(JSON.stringify(event, null, 2), { event });
37
+ } catch (e: any) {
38
+ return client.err(e.message);
39
+ }
40
+ },
41
+ });
42
+
43
+ pi.registerTool({
44
+ name: "feed_list",
45
+ label: "Feed: List Events",
46
+ description: "List recent activity feed events. Optionally filter by agent, type, or limit.",
47
+ parameters: Type.Object({
48
+ agent: Type.Optional(Type.String({ description: "Filter by agent name" })),
49
+ type: Type.Optional(Type.String({ description: "Filter by event type" })),
50
+ limit: Type.Optional(Type.Number({ description: "Max events to return (default 50)" })),
51
+ }),
52
+ async execute(_id, params) {
53
+ if (!client.getBaseUrl()) return client.noUrl();
54
+ try {
55
+ const qs = new URLSearchParams();
56
+ if (params.agent) qs.set("agent", params.agent);
57
+ if (params.type) qs.set("type", params.type);
58
+ if (params.limit) qs.set("limit", String(params.limit));
59
+ const query = qs.toString();
60
+ const result = await client.api("GET", `/feed/events${query ? `?${query}` : ""}`);
61
+ return client.ok(JSON.stringify(result, null, 2), { result });
62
+ } catch (e: any) {
63
+ return client.err(e.message);
64
+ }
65
+ },
66
+ });
67
+
68
+ pi.registerTool({
69
+ name: "feed_stats",
70
+ label: "Feed: Stats",
71
+ description: "Get summary statistics of the activity feed.",
72
+ parameters: Type.Object({}),
73
+ async execute() {
74
+ if (!client.getBaseUrl()) return client.noUrl();
75
+ try {
76
+ const stats = await client.api("GET", "/feed/stats");
77
+ return client.ok(JSON.stringify(stats, null, 2), { stats });
78
+ } catch (e: any) {
79
+ return client.err(e.message);
80
+ }
81
+ },
82
+ });
83
+ }