@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,117 @@
1
+ /**
2
+ * Feed service module — activity event stream for coordination and observability.
3
+ *
4
+ * Listens to server-side events from other modules and auto-publishes
5
+ * them as feed events:
6
+ * board:task_created → feed event "task_started"
7
+ * board:task_updated → feed event "task_completed" (if status=done)
8
+ * board:task_deleted → (ignored)
9
+ */
10
+
11
+ import type { ServiceModule, ServiceContext, FleetClient } from "../src/core/types.js";
12
+ import { FeedStore } from "./store.js";
13
+ import { createRoutes } from "./routes.js";
14
+ import { registerTools } from "./tools.js";
15
+ import { registerBehaviors } from "./behaviors.js";
16
+
17
+ const store = new FeedStore();
18
+
19
+ const feed: ServiceModule = {
20
+ name: "feed",
21
+ description: "Activity event stream",
22
+ routes: createRoutes(store),
23
+ registerTools,
24
+ registerBehaviors,
25
+
26
+ routeDocs: {
27
+ "POST /events": {
28
+ summary: "Publish an event to the feed",
29
+ body: {
30
+ agent: { type: "string", required: true, description: "Agent name that produced the event" },
31
+ type: { type: "string", required: true, description: "task_started | task_completed | task_failed | blocker_found | question | finding | skill_proposed | file_changed | cost_update | agent_started | agent_stopped | token_update | custom" },
32
+ summary: { type: "string", required: true, description: "Short human-readable summary" },
33
+ detail: { type: "string", description: "Longer detail or structured JSON string" },
34
+ metadata: { type: "object", description: "Arbitrary key-value metadata" },
35
+ },
36
+ response: "The created event with generated ID and timestamp",
37
+ },
38
+ "GET /events": {
39
+ summary: "List recent events with optional filters",
40
+ query: {
41
+ agent: { type: "string", description: "Filter by agent name" },
42
+ type: { type: "string", description: "Filter by event type" },
43
+ since: { type: "string", description: "ULID or ISO timestamp — return events after this point" },
44
+ limit: { type: "number", description: "Max events to return. Default: 50" },
45
+ },
46
+ response: "Array of feed events, most recent last",
47
+ },
48
+ "GET /events/:id": {
49
+ summary: "Get a single event by ID",
50
+ params: { id: { type: "string", required: true, description: "Event ID (ULID)" } },
51
+ },
52
+ "DELETE /events": {
53
+ summary: "Clear all events",
54
+ detail: "Destructive — removes all events from memory and disk. Use with caution.",
55
+ response: "{ ok: true }",
56
+ },
57
+ "GET /stats": {
58
+ summary: "Get feed statistics",
59
+ response: "{ total, byAgent: { [name]: count }, byType: { [type]: count }, latestPerAgent: { [name]: Event } }",
60
+ },
61
+ "GET /stream": {
62
+ summary: "Server-Sent Events stream of new events in real time",
63
+ detail: "Connect with EventSource. Pass ?since=<ulid> to replay missed events on reconnect. Pass ?agent=<name> to filter by agent. Sends heartbeat comments every 15s.",
64
+ query: {
65
+ agent: { type: "string", description: "Filter to events from this agent only" },
66
+ since: { type: "string", description: "ULID — replay events since this ID before streaming" },
67
+ },
68
+ response: "SSE stream. Each message data is a JSON-encoded FeedEvent.",
69
+ },
70
+ },
71
+
72
+ init(ctx: ServiceContext) {
73
+ ctx.events.on("board:task_created", (data: any) => {
74
+ const task = data.task;
75
+ store.publish({
76
+ agent: task.createdBy || "unknown",
77
+ type: "task_started",
78
+ summary: `Task created: ${task.title}`,
79
+ metadata: { taskId: task.id, status: task.status },
80
+ });
81
+ });
82
+
83
+ ctx.events.on("board:task_updated", (data: any) => {
84
+ const task = data.task;
85
+ const changes = data.changes || {};
86
+
87
+ if (changes.status === "done") {
88
+ store.publish({
89
+ agent: task.assignee || task.createdBy || "unknown",
90
+ type: "task_completed",
91
+ summary: `Task completed: ${task.title}`,
92
+ metadata: { taskId: task.id },
93
+ });
94
+ } else if (changes.status === "blocked") {
95
+ store.publish({
96
+ agent: task.assignee || task.createdBy || "unknown",
97
+ type: "blocker_found",
98
+ summary: `Task blocked: ${task.title}`,
99
+ metadata: { taskId: task.id },
100
+ });
101
+ }
102
+ });
103
+ },
104
+
105
+ widget: {
106
+ async getLines(client: FleetClient) {
107
+ try {
108
+ const stats = await client.api<{ total: number }>("GET", "/feed/stats");
109
+ return [`Feed: ${stats.total} events`];
110
+ } catch {
111
+ return [];
112
+ }
113
+ },
114
+ },
115
+ };
116
+
117
+ export default feed;
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import type { ServiceModule } from "../src/core/types.js";
2
+ import { JournalStore } from "./store.js";
3
+ import { createRoutes } from "./routes.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ const store = new JournalStore();
7
+
8
+ const journal: ServiceModule = {
9
+ name: "journal",
10
+ description: "Personal narrative log",
11
+ routes: createRoutes(store),
12
+ registerTools,
13
+ };
14
+
15
+ export default journal;
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
3
+ import journal from "./index.js";
4
+
5
+ let t: TestHarness;
6
+ const setup = (async () => {
7
+ t = await createTestHarness({ services: [journal] });
8
+ })();
9
+ afterAll(() => t?.cleanup());
10
+
11
+ describe("journal", () => {
12
+ test("writes an entry", async () => {
13
+ await setup;
14
+ const { status, data } = await t.json("/journal", {
15
+ method: "POST",
16
+ auth: true,
17
+ body: { text: "Feeling good about progress", author: "agent-1", mood: "optimistic" },
18
+ });
19
+ expect(status).toBe(201);
20
+ expect(data.text).toBe("Feeling good about progress");
21
+ expect(data.mood).toBe("optimistic");
22
+ expect(data.timestamp).toBeDefined();
23
+ });
24
+
25
+ test("lists entries", async () => {
26
+ await setup;
27
+ const { status, data } = await t.json<{ entries: any[]; count: number }>("/journal", {
28
+ auth: true,
29
+ });
30
+ expect(status).toBe(200);
31
+ expect(data.entries.length).toBeGreaterThanOrEqual(1);
32
+ });
33
+
34
+ test("returns raw text format", async () => {
35
+ await setup;
36
+ const res = await t.fetch("/journal/raw", { auth: true });
37
+ expect(res.status).toBe(200);
38
+ const text = await res.text();
39
+ expect(text).toContain("Feeling good about progress");
40
+ });
41
+
42
+ test("requires text field", async () => {
43
+ await setup;
44
+ const { status } = await t.json("/journal", {
45
+ method: "POST",
46
+ auth: true,
47
+ body: { author: "test" },
48
+ });
49
+ expect(status).toBe(400);
50
+ });
51
+
52
+ test("requires auth", async () => {
53
+ await setup;
54
+ const { status } = await t.json("/journal");
55
+ expect(status).toBe(401);
56
+ });
57
+ });