@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.
- package/.github/workflows/test.yml +47 -0
- package/README.md +257 -0
- package/bun.lock +587 -0
- package/examples/services/board/board.test.ts +215 -0
- package/examples/services/board/index.ts +155 -0
- package/examples/services/board/routes.ts +335 -0
- package/examples/services/board/store.ts +329 -0
- package/examples/services/board/tools.ts +214 -0
- package/examples/services/commits/commits.test.ts +74 -0
- package/examples/services/commits/index.ts +14 -0
- package/examples/services/commits/routes.ts +43 -0
- package/examples/services/commits/store.ts +114 -0
- package/examples/services/feed/behaviors.ts +23 -0
- package/examples/services/feed/feed.test.ts +101 -0
- package/examples/services/feed/index.ts +117 -0
- package/examples/services/feed/routes.ts +224 -0
- package/examples/services/feed/store.ts +194 -0
- package/examples/services/feed/tools.ts +83 -0
- package/examples/services/journal/index.ts +15 -0
- package/examples/services/journal/journal.test.ts +57 -0
- package/examples/services/journal/routes.ts +45 -0
- package/examples/services/journal/store.ts +119 -0
- package/examples/services/journal/tools.ts +32 -0
- package/examples/services/log/index.ts +15 -0
- package/examples/services/log/log.test.ts +70 -0
- package/examples/services/log/routes.ts +44 -0
- package/examples/services/log/store.ts +105 -0
- package/examples/services/log/tools.ts +57 -0
- package/examples/services/registry/behaviors.ts +128 -0
- package/examples/services/registry/index.ts +37 -0
- package/examples/services/registry/registry.test.ts +135 -0
- package/examples/services/registry/routes.ts +76 -0
- package/examples/services/registry/store.ts +224 -0
- package/examples/services/registry/tools.ts +116 -0
- package/examples/services/reports/index.ts +14 -0
- package/examples/services/reports/reports.test.ts +75 -0
- package/examples/services/reports/routes.ts +42 -0
- package/examples/services/reports/store.ts +110 -0
- package/examples/services/ui/auth.ts +61 -0
- package/examples/services/ui/index.ts +16 -0
- package/examples/services/ui/routes.ts +160 -0
- package/examples/services/ui/static/app.js +369 -0
- package/examples/services/ui/static/index.html +42 -0
- package/examples/services/ui/static/style.css +157 -0
- package/examples/services/usage/behaviors.ts +166 -0
- package/examples/services/usage/index.ts +19 -0
- package/examples/services/usage/routes.ts +53 -0
- package/examples/services/usage/store.ts +341 -0
- package/examples/services/usage/tools.ts +75 -0
- package/examples/services/usage/usage.test.ts +91 -0
- package/package.json +29 -0
- package/services/agent/index.ts +465 -0
- package/services/board/index.ts +155 -0
- package/services/board/routes.ts +335 -0
- package/services/board/store.ts +329 -0
- package/services/board/tools.ts +214 -0
- package/services/docs/index.ts +391 -0
- package/services/feed/behaviors.ts +23 -0
- package/services/feed/index.ts +117 -0
- package/services/feed/routes.ts +224 -0
- package/services/feed/store.ts +194 -0
- package/services/feed/tools.ts +83 -0
- package/services/installer/index.ts +574 -0
- package/services/services/index.ts +165 -0
- package/services/ui/auth.ts +61 -0
- package/services/ui/index.ts +16 -0
- package/services/ui/routes.ts +160 -0
- package/services/ui/static/app.js +369 -0
- package/services/ui/static/index.html +42 -0
- package/services/ui/static/style.css +157 -0
- package/skills/create-service/SKILL.md +698 -0
- package/src/core/auth.ts +28 -0
- package/src/core/client.ts +99 -0
- package/src/core/discover.ts +152 -0
- package/src/core/events.ts +44 -0
- package/src/core/extension.ts +66 -0
- package/src/core/server.ts +262 -0
- package/src/core/testing.ts +155 -0
- package/src/core/types.ts +194 -0
- package/src/extension.ts +16 -0
- package/src/main.ts +11 -0
- package/tests/server.test.ts +1338 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { JournalStore } from "./store.js";
|
|
3
|
+
import { ValidationError } from "./store.js";
|
|
4
|
+
|
|
5
|
+
export function createRoutes(store: JournalStore): Hono {
|
|
6
|
+
const routes = new Hono();
|
|
7
|
+
|
|
8
|
+
routes.post("/", async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const body = await c.req.json();
|
|
11
|
+
const entry = store.append(body);
|
|
12
|
+
return c.json(entry, 201);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
routes.get("/", (c) => {
|
|
20
|
+
const entries = store.query({
|
|
21
|
+
since: c.req.query("since") || undefined,
|
|
22
|
+
until: c.req.query("until") || undefined,
|
|
23
|
+
last: c.req.query("last") || undefined,
|
|
24
|
+
author: c.req.query("author") || undefined,
|
|
25
|
+
tag: c.req.query("tag") || undefined,
|
|
26
|
+
});
|
|
27
|
+
if (c.req.query("raw") === "true") {
|
|
28
|
+
return c.text(store.formatRaw(entries));
|
|
29
|
+
}
|
|
30
|
+
return c.json({ entries, count: entries.length });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
routes.get("/raw", (c) => {
|
|
34
|
+
const entries = store.query({
|
|
35
|
+
since: c.req.query("since") || undefined,
|
|
36
|
+
until: c.req.query("until") || undefined,
|
|
37
|
+
last: c.req.query("last") || undefined,
|
|
38
|
+
author: c.req.query("author") || undefined,
|
|
39
|
+
tag: c.req.query("tag") || undefined,
|
|
40
|
+
});
|
|
41
|
+
return c.text(store.formatRaw(entries));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return routes;
|
|
45
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal store — personal narrative log. Thoughts, vibes, intuitions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ulid } from "ulid";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface JournalEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
text: string;
|
|
13
|
+
author: string;
|
|
14
|
+
mood?: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AppendInput {
|
|
19
|
+
text: string;
|
|
20
|
+
author: string;
|
|
21
|
+
mood?: string;
|
|
22
|
+
tags?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QueryOptions {
|
|
26
|
+
since?: string;
|
|
27
|
+
until?: string;
|
|
28
|
+
last?: string;
|
|
29
|
+
author?: string;
|
|
30
|
+
tag?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ValidationError extends Error {
|
|
34
|
+
constructor(message: string) { super(message); this.name = "ValidationError"; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseDuration(duration: string): number | null {
|
|
38
|
+
const match = duration.match(/^(\d+)(h|d)$/);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const value = parseInt(match[1], 10);
|
|
41
|
+
const unit = match[2];
|
|
42
|
+
if (unit === "h") return value * 3600_000;
|
|
43
|
+
if (unit === "d") return value * 86400_000;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class JournalStore {
|
|
48
|
+
private entries: JournalEntry[] = [];
|
|
49
|
+
private filePath: string;
|
|
50
|
+
|
|
51
|
+
constructor(filePath = "data/journal.jsonl") {
|
|
52
|
+
this.filePath = filePath;
|
|
53
|
+
this.load();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private load(): void {
|
|
57
|
+
if (!existsSync(this.filePath)) return;
|
|
58
|
+
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
59
|
+
if (!content) return;
|
|
60
|
+
for (const line of content.split("\n")) {
|
|
61
|
+
if (!line.trim()) continue;
|
|
62
|
+
try { this.entries.push(JSON.parse(line)); } catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
append(input: AppendInput): JournalEntry {
|
|
67
|
+
if (!input.text?.trim()) throw new ValidationError("text is required");
|
|
68
|
+
if (!input.author?.trim()) throw new ValidationError("author is required");
|
|
69
|
+
|
|
70
|
+
const entry: JournalEntry = {
|
|
71
|
+
id: ulid(),
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
text: input.text.trim(),
|
|
74
|
+
author: input.author.trim(),
|
|
75
|
+
tags: input.tags || [],
|
|
76
|
+
};
|
|
77
|
+
if (input.mood?.trim()) entry.mood = input.mood.trim();
|
|
78
|
+
|
|
79
|
+
const dir = dirname(this.filePath);
|
|
80
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
81
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
82
|
+
this.entries.push(entry);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
query(opts: QueryOptions = {}): JournalEntry[] {
|
|
87
|
+
let sinceTime: number | undefined;
|
|
88
|
+
let untilTime: number | undefined;
|
|
89
|
+
|
|
90
|
+
if (opts.last) {
|
|
91
|
+
const ms = parseDuration(opts.last);
|
|
92
|
+
if (ms !== null) sinceTime = Date.now() - ms;
|
|
93
|
+
}
|
|
94
|
+
if (opts.since) sinceTime = new Date(opts.since).getTime();
|
|
95
|
+
if (opts.until) untilTime = new Date(opts.until).getTime();
|
|
96
|
+
if (sinceTime === undefined && untilTime === undefined) {
|
|
97
|
+
sinceTime = Date.now() - 86400_000;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let result = this.entries;
|
|
101
|
+
if (sinceTime !== undefined) result = result.filter((e) => new Date(e.timestamp).getTime() >= sinceTime!);
|
|
102
|
+
if (untilTime !== undefined) result = result.filter((e) => new Date(e.timestamp).getTime() <= untilTime!);
|
|
103
|
+
if (opts.author) result = result.filter((e) => e.author === opts.author);
|
|
104
|
+
if (opts.tag) result = result.filter((e) => e.tags.includes(opts.tag!));
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
formatRaw(entries: JournalEntry[]): string {
|
|
109
|
+
return entries
|
|
110
|
+
.map((e) => {
|
|
111
|
+
const mood = e.mood ? ` [${e.mood}]` : "";
|
|
112
|
+
const tags = e.tags.length ? ` #${e.tags.join(" #")}` : "";
|
|
113
|
+
return `[${e.timestamp}] (${e.author})${mood}${tags} ${e.text}`;
|
|
114
|
+
})
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get size(): number { return this.entries.length; }
|
|
119
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { FleetClient } from "../src/core/types.js";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
|
|
5
|
+
export function registerTools(pi: ExtensionAPI, client: FleetClient) {
|
|
6
|
+
pi.registerTool({
|
|
7
|
+
name: "journal_entry",
|
|
8
|
+
label: "Journal: Write Entry",
|
|
9
|
+
description:
|
|
10
|
+
"Write a personal journal entry — thoughts, vibes, product intuitions, feelings. NOT for operational tasks (use log_append for that).",
|
|
11
|
+
parameters: Type.Object({
|
|
12
|
+
text: Type.String({ description: "Journal entry text" }),
|
|
13
|
+
mood: Type.Optional(Type.String({ description: "Optional mood/vibe tag" })),
|
|
14
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: "Optional tags" })),
|
|
15
|
+
}),
|
|
16
|
+
async execute(_id, params) {
|
|
17
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
18
|
+
try {
|
|
19
|
+
const body: Record<string, unknown> = {
|
|
20
|
+
text: params.text,
|
|
21
|
+
author: client.agentName,
|
|
22
|
+
};
|
|
23
|
+
if (params.mood) body.mood = params.mood;
|
|
24
|
+
if (params.tags) body.tags = params.tags;
|
|
25
|
+
const entry = await client.api("POST", "/journal", body);
|
|
26
|
+
return client.ok(JSON.stringify(entry, null, 2), { entry });
|
|
27
|
+
} catch (e: any) {
|
|
28
|
+
return client.err(e.message);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ServiceModule } from "../src/core/types.js";
|
|
2
|
+
import { LogStore } from "./store.js";
|
|
3
|
+
import { createRoutes } from "./routes.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
const store = new LogStore();
|
|
7
|
+
|
|
8
|
+
const log: ServiceModule = {
|
|
9
|
+
name: "log",
|
|
10
|
+
description: "Append-only work log",
|
|
11
|
+
routes: createRoutes(store),
|
|
12
|
+
registerTools,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default log;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
|
|
3
|
+
import log from "./index.js";
|
|
4
|
+
|
|
5
|
+
let t: TestHarness;
|
|
6
|
+
const setup = (async () => {
|
|
7
|
+
t = await createTestHarness({ services: [log] });
|
|
8
|
+
})();
|
|
9
|
+
afterAll(() => t?.cleanup());
|
|
10
|
+
|
|
11
|
+
describe("log", () => {
|
|
12
|
+
test("appends an entry", async () => {
|
|
13
|
+
await setup;
|
|
14
|
+
const { status, data } = await t.json("/log", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
auth: true,
|
|
17
|
+
body: { text: "Did some work", agent: "test-agent" },
|
|
18
|
+
});
|
|
19
|
+
expect(status).toBe(201);
|
|
20
|
+
expect(data.text).toBe("Did some work");
|
|
21
|
+
expect(data.agent).toBe("test-agent");
|
|
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 }>("/log", {
|
|
28
|
+
auth: true,
|
|
29
|
+
});
|
|
30
|
+
expect(status).toBe(200);
|
|
31
|
+
expect(data.entries.length).toBeGreaterThanOrEqual(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("filters by last duration", async () => {
|
|
35
|
+
await setup;
|
|
36
|
+
const { status, data } = await t.json<{ entries: any[] }>("/log?last=1h", {
|
|
37
|
+
auth: true,
|
|
38
|
+
});
|
|
39
|
+
expect(status).toBe(200);
|
|
40
|
+
// All entries should be within the last hour
|
|
41
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
42
|
+
for (const e of data.entries) {
|
|
43
|
+
expect(new Date(e.timestamp).getTime()).toBeGreaterThan(oneHourAgo);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns raw text format", async () => {
|
|
48
|
+
await setup;
|
|
49
|
+
const res = await t.fetch("/log/raw", { auth: true });
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
expect(text).toContain("Did some work");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("requires text field", async () => {
|
|
56
|
+
await setup;
|
|
57
|
+
const { status } = await t.json("/log", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
auth: true,
|
|
60
|
+
body: { agent: "test" },
|
|
61
|
+
});
|
|
62
|
+
expect(status).toBe(400);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("requires auth", async () => {
|
|
66
|
+
await setup;
|
|
67
|
+
const { status } = await t.json("/log");
|
|
68
|
+
expect(status).toBe(401);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { LogStore } from "./store.js";
|
|
3
|
+
import { ValidationError } from "./store.js";
|
|
4
|
+
|
|
5
|
+
export function createRoutes(store: LogStore): Hono {
|
|
6
|
+
const routes = new Hono();
|
|
7
|
+
|
|
8
|
+
routes.post("/", async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const body = await c.req.json();
|
|
11
|
+
const entry = store.append(body);
|
|
12
|
+
return c.json(entry, 201);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
routes.get("/", (c) => {
|
|
20
|
+
const since = c.req.query("since");
|
|
21
|
+
const until = c.req.query("until");
|
|
22
|
+
const last = c.req.query("last");
|
|
23
|
+
const entries = store.query({
|
|
24
|
+
since: since || undefined,
|
|
25
|
+
until: until || undefined,
|
|
26
|
+
last: last || undefined,
|
|
27
|
+
});
|
|
28
|
+
return c.json({ entries, count: entries.length });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
routes.get("/raw", (c) => {
|
|
32
|
+
const since = c.req.query("since");
|
|
33
|
+
const until = c.req.query("until");
|
|
34
|
+
const last = c.req.query("last");
|
|
35
|
+
const entries = store.query({
|
|
36
|
+
since: since || undefined,
|
|
37
|
+
until: until || undefined,
|
|
38
|
+
last: last || undefined,
|
|
39
|
+
});
|
|
40
|
+
return c.text(store.formatRaw(entries));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return routes;
|
|
44
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log store — append-only work log. Carmack .plan style.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ulid } from "ulid";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface LogEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
text: string;
|
|
13
|
+
agent?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AppendInput {
|
|
17
|
+
text: string;
|
|
18
|
+
agent?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QueryOptions {
|
|
22
|
+
since?: string;
|
|
23
|
+
until?: string;
|
|
24
|
+
last?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ValidationError extends Error {
|
|
28
|
+
constructor(message: string) { super(message); this.name = "ValidationError"; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseDuration(duration: string): number | null {
|
|
32
|
+
const match = duration.match(/^(\d+)(h|d)$/);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
const value = parseInt(match[1], 10);
|
|
35
|
+
const unit = match[2];
|
|
36
|
+
if (unit === "h") return value * 3600_000;
|
|
37
|
+
if (unit === "d") return value * 86400_000;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class LogStore {
|
|
42
|
+
private entries: LogEntry[] = [];
|
|
43
|
+
private filePath: string;
|
|
44
|
+
|
|
45
|
+
constructor(filePath = "data/log.jsonl") {
|
|
46
|
+
this.filePath = filePath;
|
|
47
|
+
this.load();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private load(): void {
|
|
51
|
+
if (!existsSync(this.filePath)) return;
|
|
52
|
+
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
53
|
+
if (!content) return;
|
|
54
|
+
for (const line of content.split("\n")) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
try { this.entries.push(JSON.parse(line)); } catch { /* skip */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
append(input: AppendInput): LogEntry {
|
|
61
|
+
if (!input.text?.trim()) throw new ValidationError("text is required");
|
|
62
|
+
|
|
63
|
+
const entry: LogEntry = {
|
|
64
|
+
id: ulid(),
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
text: input.text.trim(),
|
|
67
|
+
};
|
|
68
|
+
if (input.agent?.trim()) entry.agent = input.agent.trim();
|
|
69
|
+
|
|
70
|
+
const dir = dirname(this.filePath);
|
|
71
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
72
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
73
|
+
this.entries.push(entry);
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
query(opts: QueryOptions = {}): LogEntry[] {
|
|
78
|
+
let sinceTime: number | undefined;
|
|
79
|
+
let untilTime: number | undefined;
|
|
80
|
+
|
|
81
|
+
if (opts.last) {
|
|
82
|
+
const ms = parseDuration(opts.last);
|
|
83
|
+
if (ms !== null) sinceTime = Date.now() - ms;
|
|
84
|
+
}
|
|
85
|
+
if (opts.since) sinceTime = new Date(opts.since).getTime();
|
|
86
|
+
if (opts.until) untilTime = new Date(opts.until).getTime();
|
|
87
|
+
|
|
88
|
+
if (sinceTime === undefined && untilTime === undefined) {
|
|
89
|
+
sinceTime = Date.now() - 86400_000; // default: last 24h
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result = this.entries;
|
|
93
|
+
if (sinceTime !== undefined) result = result.filter((e) => new Date(e.timestamp).getTime() >= sinceTime!);
|
|
94
|
+
if (untilTime !== undefined) result = result.filter((e) => new Date(e.timestamp).getTime() <= untilTime!);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
formatRaw(entries: LogEntry[]): string {
|
|
99
|
+
return entries
|
|
100
|
+
.map((e) => `[${e.timestamp}]${e.agent ? ` (${e.agent})` : ""} ${e.text}`)
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get size(): number { return this.entries.length; }
|
|
105
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { FleetClient } from "../src/core/types.js";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
|
|
5
|
+
export function registerTools(pi: ExtensionAPI, client: FleetClient) {
|
|
6
|
+
pi.registerTool({
|
|
7
|
+
name: "log_append",
|
|
8
|
+
label: "Log: Append Entry",
|
|
9
|
+
description: "Append a work log entry — timestamped, append-only. Like Carmack's .plan file.",
|
|
10
|
+
parameters: Type.Object({
|
|
11
|
+
text: Type.String({ description: "Log entry text" }),
|
|
12
|
+
}),
|
|
13
|
+
async execute(_id, params) {
|
|
14
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
15
|
+
try {
|
|
16
|
+
const entry = await client.api("POST", "/log", {
|
|
17
|
+
text: params.text,
|
|
18
|
+
agent: client.agentName,
|
|
19
|
+
});
|
|
20
|
+
return client.ok(JSON.stringify(entry, null, 2), { entry });
|
|
21
|
+
} catch (e: any) {
|
|
22
|
+
return client.err(e.message);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
pi.registerTool({
|
|
28
|
+
name: "log_query",
|
|
29
|
+
label: "Log: Query Entries",
|
|
30
|
+
description:
|
|
31
|
+
"Query the work log. Returns timestamped entries filtered by time range. Use raw=true for plain text output.",
|
|
32
|
+
parameters: Type.Object({
|
|
33
|
+
since: Type.Optional(Type.String({ description: "Start time (ISO timestamp)" })),
|
|
34
|
+
until: Type.Optional(Type.String({ description: "End time (ISO timestamp)" })),
|
|
35
|
+
last: Type.Optional(Type.String({ description: 'Duration shorthand, e.g. "24h", "7d"' })),
|
|
36
|
+
raw: Type.Optional(Type.Boolean({ description: "Return plain text instead of JSON" })),
|
|
37
|
+
}),
|
|
38
|
+
async execute(_id, params) {
|
|
39
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
40
|
+
try {
|
|
41
|
+
const qs = new URLSearchParams();
|
|
42
|
+
if (params.since) qs.set("since", params.since);
|
|
43
|
+
if (params.until) qs.set("until", params.until);
|
|
44
|
+
if (params.last) qs.set("last", params.last);
|
|
45
|
+
const query = qs.toString();
|
|
46
|
+
const endpoint = params.raw ? "/log/raw" : "/log";
|
|
47
|
+
const result = await client.api("GET", `${endpoint}${query ? `?${query}` : ""}`);
|
|
48
|
+
if (params.raw && typeof result === "string") {
|
|
49
|
+
return client.ok(result || "(no entries)");
|
|
50
|
+
}
|
|
51
|
+
return client.ok(JSON.stringify(result, null, 2), { result });
|
|
52
|
+
} catch (e: any) {
|
|
53
|
+
return client.err(e.message);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry behaviors — auto-registration, heartbeat, lifecycle event handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { FleetClient } from "../src/core/types.js";
|
|
7
|
+
|
|
8
|
+
export function registerBehaviors(pi: ExtensionAPI, client: FleetClient) {
|
|
9
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
10
|
+
|
|
11
|
+
// Auto-register this VM on agent start
|
|
12
|
+
pi.on("agent_start", async () => {
|
|
13
|
+
if (!client.getBaseUrl() || !client.vmId) return;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await client.api("POST", "/registry/vms", {
|
|
17
|
+
id: client.vmId,
|
|
18
|
+
name: client.agentName,
|
|
19
|
+
role: client.agentRole,
|
|
20
|
+
address: `${client.vmId}.vm.vers.sh`,
|
|
21
|
+
registeredBy: client.agentName,
|
|
22
|
+
metadata: { pid: process.pid, startedAt: new Date().toISOString() },
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
// Might already exist — try update instead
|
|
26
|
+
try {
|
|
27
|
+
await client.api("PATCH", `/registry/vms/${client.vmId}`, {
|
|
28
|
+
name: client.agentName,
|
|
29
|
+
status: "running",
|
|
30
|
+
});
|
|
31
|
+
} catch { /* best-effort */ }
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Mark stopped on agent end
|
|
36
|
+
pi.on("agent_end", async () => {
|
|
37
|
+
if (!client.getBaseUrl() || !client.vmId) return;
|
|
38
|
+
try {
|
|
39
|
+
await client.api("PATCH", `/registry/vms/${client.vmId}`, { status: "stopped" });
|
|
40
|
+
} catch { /* best-effort */ }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Start heartbeat timer on session start
|
|
44
|
+
pi.on("session_start", async () => {
|
|
45
|
+
if (!client.getBaseUrl() || !client.vmId) return;
|
|
46
|
+
|
|
47
|
+
heartbeatTimer = setInterval(async () => {
|
|
48
|
+
try {
|
|
49
|
+
await client.api("POST", `/registry/vms/${client.vmId}/heartbeat`);
|
|
50
|
+
} catch { /* best-effort */ }
|
|
51
|
+
}, 60_000);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Stop heartbeat on shutdown
|
|
55
|
+
pi.on("session_shutdown", async () => {
|
|
56
|
+
if (heartbeatTimer) {
|
|
57
|
+
clearInterval(heartbeatTimer);
|
|
58
|
+
heartbeatTimer = null;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Handle swarm/lieutenant lifecycle events from other extensions
|
|
63
|
+
pi.events.on("vers:agent_spawned", async (data: {
|
|
64
|
+
vmId: string; label: string; role: string; address: string; commitId?: string;
|
|
65
|
+
}) => {
|
|
66
|
+
if (!client.getBaseUrl()) return;
|
|
67
|
+
try {
|
|
68
|
+
await client.api("POST", "/registry/vms", {
|
|
69
|
+
id: data.vmId,
|
|
70
|
+
name: data.label,
|
|
71
|
+
role: data.role || "worker",
|
|
72
|
+
address: data.address,
|
|
73
|
+
registeredBy: "reef",
|
|
74
|
+
metadata: {
|
|
75
|
+
agentId: data.label,
|
|
76
|
+
commitId: data.commitId,
|
|
77
|
+
registeredVia: "vers:agent_spawned",
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[registry] Registration failed for ${data.label}: ${err instanceof Error ? err.message : err}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.events.on("vers:agent_destroyed", async (data: { vmId: string; label: string }) => {
|
|
87
|
+
if (!client.getBaseUrl()) return;
|
|
88
|
+
try {
|
|
89
|
+
await client.api("DELETE", `/registry/vms/${encodeURIComponent(data.vmId)}`);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[registry] Delete failed for ${data.label}: ${err instanceof Error ? err.message : err}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.events.on("vers:lt_created", async (data: {
|
|
96
|
+
vmId: string; name: string; role: string; address: string;
|
|
97
|
+
ltRole?: string; commitId?: string; createdAt?: string;
|
|
98
|
+
}) => {
|
|
99
|
+
if (!client.getBaseUrl()) return;
|
|
100
|
+
try {
|
|
101
|
+
await client.api("POST", "/registry/vms", {
|
|
102
|
+
id: data.vmId,
|
|
103
|
+
name: data.name,
|
|
104
|
+
role: data.role || "lieutenant",
|
|
105
|
+
address: data.address,
|
|
106
|
+
registeredBy: "reef",
|
|
107
|
+
metadata: {
|
|
108
|
+
agentId: data.name,
|
|
109
|
+
role: data.ltRole,
|
|
110
|
+
commitId: data.commitId,
|
|
111
|
+
createdAt: data.createdAt,
|
|
112
|
+
registeredVia: "vers:lt_created",
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`[registry] LT registration failed for ${data.name}: ${err instanceof Error ? err.message : err}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.events.on("vers:lt_destroyed", async (data: { vmId: string; name: string }) => {
|
|
121
|
+
if (!client.getBaseUrl()) return;
|
|
122
|
+
try {
|
|
123
|
+
await client.api("DELETE", `/registry/vms/${encodeURIComponent(data.vmId)}`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`[registry] LT delete failed for ${data.name}: ${err instanceof Error ? err.message : err}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry service module — VM service discovery for agent fleets.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ServiceModule, FleetClient } from "../src/core/types.js";
|
|
6
|
+
import { RegistryStore } from "./store.js";
|
|
7
|
+
import { createRoutes } from "./routes.js";
|
|
8
|
+
import { registerTools } from "./tools.js";
|
|
9
|
+
import { registerBehaviors } from "./behaviors.js";
|
|
10
|
+
|
|
11
|
+
const store = new RegistryStore();
|
|
12
|
+
|
|
13
|
+
const registry: ServiceModule = {
|
|
14
|
+
name: "registry",
|
|
15
|
+
description: "VM service discovery",
|
|
16
|
+
routes: createRoutes(store),
|
|
17
|
+
store,
|
|
18
|
+
registerTools,
|
|
19
|
+
registerBehaviors,
|
|
20
|
+
|
|
21
|
+
widget: {
|
|
22
|
+
async getLines(client: FleetClient) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await client.api<{ vms: { status: string }[]; count: number }>(
|
|
25
|
+
"GET",
|
|
26
|
+
"/registry/vms",
|
|
27
|
+
);
|
|
28
|
+
const running = res.vms.filter((v) => v.status === "running").length;
|
|
29
|
+
return [`Registry: ${res.count} VMs (${running} running)`];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default registry;
|