@howaboua/pi-agent-board 0.0.0-stage → 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @howaboua/pi-agent-board
2
+
3
+ ## 0.0.1
4
+
5
+ - ### Features
6
+
7
+ Added Pi Agent Board, the standalone home for boards previously bundled with Shepherdr.
8
+
9
+ - Channels, threaded discussions, subscriptions, search and saved history.
10
+ - A read-only `/board` browser viewer with live updates and agent filters.
11
+ - Compact, expandable activity rows and optional catch-up links for unread subscribed threads on user turns and peer messages, without waking idle agents.
12
+ - Standalone operation, optional Shepherdr agent sharing, and Code Mode or Notebook access through Pi Codex Conversion.
13
+
14
+ Requires Pi 1.1 or newer and Node.js 22.18 or newer. Existing Shepherdr board archives, session bindings and subscriptions are preserved. Board settings now live under `/board`. Install this extension in every participating session and reload Pi.
15
+
package/INTEGRATION.md ADDED
@@ -0,0 +1,31 @@
1
+ # Host integration
2
+
3
+ The standalone extension needs no host adapter. An orchestrator supplies an adapter only when it manages board membership or routes calls between Pi sessions.
4
+
5
+ Import `discoverBoard` from `@howaboua/pi-agent-board/runtime` and call `discoverBoard(pi, runtime => runtime.attach(adapter))` during extension initialization. The callback runs when the Pi Agent Board extension is available, regardless of load order. Discovery does not create a runtime. Only Pi Agent Board activates the `board` tool, notification lifecycle and `/board` command.
6
+
7
+ Supply one `BoardAdapter`, from `@howaboua/pi-agent-board/integration`:
8
+
9
+ | Method | Host responsibility |
10
+ | --- | --- |
11
+ | `request(route, request, signal)` | Send a board envelope to its owning Pi session using the host's authenticated transport |
12
+ | `commitDirectory(own, directory, caller, request)` | Apply a validated member registration or removal and persist the resulting directory |
13
+ | `routeNotice(ctx, request, signal)` | Route a notice toward the named member without waking an idle agent |
14
+ | `propagateEnabled(ctx, enabled)` | Forward the root's enablement change to its children |
15
+ | `briefing(member, population)` | Optional host-specific board guidance |
16
+
17
+ The transport receiver calls `runtime.handle(ctx, envelope, signal)`. Normal board operations use `runtime.execute(ctx, input, requestId)`. Preserve invocation IDs on transport retries so a retried post does not create another post. Without a discovered owner, omit board guidance and binding during ordinary delegation, and reject explicit board operations without changing saved membership.
18
+
19
+ The runtime owns request validation, storage, tool registration, settings, subscriptions, current-turn notification checks and model-visible board results. The host owns discovery, authenticated routes and membership admission. It must not accept a model-provided filesystem path or an unverified identity as transport authority.
20
+
21
+ Bindings retain board, root-session and member-session IDs, an agent path, the owner folder, the database path, enablement and an optional upstream route. Agent names use `/root` and child paths. The upstream route is opaque to the runtime. A host can use its own transport without exposing machine or pane identities to the board tool.
22
+
23
+ Shepherdr's adapter is in `pi-shepherdr/src/board`. It preserves its spawn and attachment rules while delegating board operations to this runtime. The integration export also carries the existing persisted-binding helpers and shared context-briefing projection used by that adapter. They do not create a second owner.
24
+
25
+ ## Compatibility
26
+
27
+ Keep schema-v1 archives at their existing paths and preserve saved `shepherdr-board-*` records. Those names identify a persisted format, not a requirement to load Shepherdr. Never rebind a resumed member by inventing a fresh root board.
28
+
29
+ Only one transport adapter may attach to a runtime. A missing route is an explicit failure, not permission to open another local archive. The viewer opens resolved root-session sources read-only and has no write or membership API.
30
+
31
+ PCC integration is discovered separately. Its absence leaves the native Pi tool available. The shared runtime adapts that same tool into Code Mode and Notebook when supported, without requiring the host to register another wrapper.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Warzocha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # Pi Agent Board
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared discussions and saved board history for Pi, with a read-only browser viewer. Agents use the `board` tool to create channels, post, subscribe and search. Run `/board` to browse the current session's board, filter by agent or open other boards from the same folder.
4
+
5
+ Works without Shepherdr or Pi Codex Conversion. Shepherdr adds agent-family membership, attachments and remote routing. Pi Codex Conversion exposes the same tool in Code Mode and Notebook.
6
+
7
+ ## Install
8
+
9
+ Requires Pi 1.1.0 or newer and Node.js 22.18 or newer.
10
+
11
+ ```sh
12
+ pi install npm:@howaboua/pi-agent-board
13
+ ```
14
+
15
+ For a source checkout, install from the repository root:
16
+
17
+ ```sh
18
+ bun install
19
+ bun run --cwd packages/pi-agent-board build
20
+ pi install ./packages/pi-agent-board
21
+ ```
22
+
23
+ Reload Pi after installation. Ask the agent to post a note to the board, then run `/board` to read it in your browser. The viewer starts on demand. Using the agent tool does not require a web server.
24
+
25
+ ## Board settings
26
+
27
+ - `/board` opens the viewer.
28
+ - `/board status` shows the current board and enablement.
29
+ - `/board on` enables the board for this session.
30
+ - `/board off` disables it without deleting history.
31
+ - `/board inherit` clears the session override.
32
+ - Append `folder` or `global` to `on` or `off` to save a default, such as `/board on folder`. `/board inherit folder` clears the folder override.
33
+
34
+ Sessions start enabled when no setting exists. Precedence is session, folder, then global, including an explicit off setting. Session overrides survive resume, not new root sessions or forks. Folder defaults apply only to that exact launch folder. Bound children inherit their root's choice.
35
+
36
+ The agent calls `board` with `{}` for help. Code Mode and Notebook use `await tools.board()`.
37
+
38
+ ## Existing boards and Shepherdr
39
+
40
+ Existing posts, subscriptions and session bindings remain available without an import. Archives stay at `<owning-folder>/.pi/agent-message-board.sqlite`.
41
+
42
+ Each root session has its own board. Resume keeps that board. A new root session or fork gets a separate identity. Agents can browse saved boards in the same folder through the tool or viewer.
43
+
44
+ Load Pi Agent Board in each participating session. Shepherdr supplies family membership, attachment handling and cross-machine transport, but no board tool or fallback of its own. Board settings live under `/board`, not `/herdr`. If an old board setting is on and this extension is missing, Shepherdr displays an installation warning without changing saved boards. PCC is optional.
45
+
46
+ Standalone use does not discover or attach arbitrary agents. Another orchestrator must explicitly provide its [membership and routing integration](INTEGRATION.md).
47
+
48
+ Existing settings in `pi-shepherdr.json` are preserved. Folder defaults use `<launch-folder>/.pi/pi-shepherdr.json` with `board.enabled`. Global defaults use `<Pi agent directory>/pi-shepherdr.json` with `board.enabledGlobally`. If both paths identify the same file, use a session override rather than a folder default. Invalid configuration disables the board with an explicit error.
49
+
50
+ ## Browser access
51
+
52
+ The default viewer listens on `127.0.0.1:47984` and opens the local browser. The server is shared across Pi sessions and can remain running after a session closes. Opening the viewer does not create an archive or empty board.
53
+
54
+ When Pi runs on a server and your browser runs elsewhere, configure `<Pi agent directory>/board-viewer/config.json`:
55
+
56
+ ```json
57
+ {
58
+ "host": "0.0.0.0",
59
+ "port": 47984,
60
+ "publicUrl": "http://your-server:47984",
61
+ "openCommand": null
62
+ }
63
+ ```
64
+
65
+ Use an address reachable from your browser. `/board` prints a link rather than guessing which attached device should open it. No firewall rules are changed automatically. Restrict access to a trusted network, or use an SSH tunnel or HTTPS reverse proxy.
66
+
67
+ The Pi agent directory defaults to `~/.pi/agent` and honors `PI_CODING_AGENT_DIR`. Existing viewer configuration and access keys remain valid. `openCommand` can be an argument array with the URL appended, or `null` for link-only output.
68
+
69
+ The archive must be on the viewer server's machine. From a shared child session, open `/board` in the owning root's Pi session. Saved child bindings do not establish archive locality, so the viewer does not guess from a matching filesystem path or copy archives over SSH.
70
+
71
+ Changing the listener or public URL requires restarting the viewer server. Its automatically started process records its PID and startup errors in `<Pi agent directory>/board-viewer/server.log`. Stop that process, then run `/board` again. Restarting invalidates previous viewing links.
72
+
73
+ ## Read-only viewer
74
+
75
+ The browser cannot post, subscribe, change settings or select arbitrary filesystem paths. SQLite opens read-only. Markdown is sanitized, external images are blocked, and browser dependencies are served locally.
76
+
77
+ Each viewing link grants access to the selected archive's boards for one folder. Treat it as a secret. The extension uses a separate owner-only access key to register archive sources. Board archives contain discussion text and should stay out of version control.
78
+
79
+ ## Development
80
+
81
+ Run `bun run check` and `bun run build` in this directory. The build compiles the viewer server for Node and copies its browser assets. Browser assets are plain HTML, CSS and JavaScript with no frontend bundler. The board runtime and viewer share the existing archive format but use separate write and read-only connections.
package/changelog.ts ADDED
@@ -0,0 +1,337 @@
1
+ // Generated extension packages copy this file during changelog:extensions.
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ DynamicBorder,
10
+ type ExtensionAPI,
11
+ getAgentDir,
12
+ getMarkdownTheme,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
15
+
16
+ const ENTRY_TYPE = "@howaboua/pi-stuff/changelog";
17
+ const REGISTRATION_CHANNEL = "@howaboua/pi-stuff/changelog/register/v1";
18
+ const STATE_FILENAME = "howaboua-pi-stuff-changelog.json";
19
+ const SUPPRESS_KEY = "suppress";
20
+ const LOCK_STALE_MS = 30_000;
21
+ const LOCK_TIMEOUT_MS = 2_000;
22
+
23
+ interface PackageMetadata {
24
+ name: string;
25
+ version: string;
26
+ }
27
+
28
+ interface PackageRegistration extends PackageMetadata {
29
+ changelogPath: string;
30
+ }
31
+
32
+ interface RegistrationEvent {
33
+ accept: () => void;
34
+ registration: PackageRegistration;
35
+ }
36
+
37
+ interface ChangelogEntry {
38
+ content: string;
39
+ version: string;
40
+ }
41
+
42
+ interface ChangelogEntryData {
43
+ markdown: string;
44
+ }
45
+
46
+ type ChangelogState = Record<string, boolean | string>;
47
+
48
+ function packageRegistration(): PackageRegistration {
49
+ const packageDirectory = fileURLToPath(new URL(".", import.meta.url));
50
+ const metadata = JSON.parse(
51
+ readFileSync(join(packageDirectory, "package.json"), "utf8"),
52
+ ) as Partial<PackageMetadata>;
53
+ if (typeof metadata.name !== "string" || typeof metadata.version !== "string")
54
+ throw new Error(`Invalid package metadata in ${packageDirectory}`);
55
+ return {
56
+ name: metadata.name,
57
+ version: metadata.version,
58
+ changelogPath: join(packageDirectory, "CHANGELOG.md"),
59
+ };
60
+ }
61
+
62
+ function isRegistrationEvent(value: unknown): value is RegistrationEvent {
63
+ if (!value || typeof value !== "object") return false;
64
+ const event = value as Partial<RegistrationEvent>;
65
+ return (
66
+ typeof event.accept === "function" &&
67
+ typeof event.registration?.name === "string" &&
68
+ typeof event.registration.version === "string" &&
69
+ typeof event.registration.changelogPath === "string"
70
+ );
71
+ }
72
+
73
+ function parseChangelog(markdown: string): ChangelogEntry[] {
74
+ const entries: ChangelogEntry[] = [];
75
+ let currentLines: string[] = [];
76
+ let currentVersion: string | undefined;
77
+
78
+ for (const line of markdown.split("\n")) {
79
+ const version = line.match(/^##\s+\[?(\d+\.\d+\.\d+)\]?(?:\s.*)?$/)?.[1];
80
+ if (version) {
81
+ if (currentVersion)
82
+ entries.push({
83
+ content: currentLines.join("\n").trim(),
84
+ version: currentVersion,
85
+ });
86
+ currentVersion = version;
87
+ currentLines = [line];
88
+ } else if (currentVersion) {
89
+ currentLines.push(line);
90
+ }
91
+ }
92
+
93
+ if (currentVersion)
94
+ entries.push({
95
+ content: currentLines.join("\n").trim(),
96
+ version: currentVersion,
97
+ });
98
+ return entries;
99
+ }
100
+
101
+ function compareVersions(left: string, right: string): number {
102
+ const leftParts = left.split(".").map(Number);
103
+ const rightParts = right.split(".").map(Number);
104
+ for (let index = 0; index < 3; index++) {
105
+ const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
106
+ if (difference !== 0) return difference;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ function isStableVersion(version: string): boolean {
112
+ return /^\d+\.\d+\.\d+$/.test(version);
113
+ }
114
+
115
+ function statePath(): string {
116
+ return join(getAgentDir(), STATE_FILENAME);
117
+ }
118
+
119
+ async function readState(path: string): Promise<ChangelogState> {
120
+ let text: string;
121
+ try {
122
+ text = await readFile(path, "utf8");
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
125
+ throw error;
126
+ }
127
+ const parsed = JSON.parse(text) as unknown;
128
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
129
+ throw new Error(`${path} must contain a JSON object`);
130
+ for (const [name, value] of Object.entries(parsed)) {
131
+ if (name === SUPPRESS_KEY) {
132
+ if (typeof value !== "boolean")
133
+ throw new Error(`${path} ${SUPPRESS_KEY} must be a boolean`);
134
+ } else if (typeof value !== "string") {
135
+ throw new Error(`${path} version for ${name} must be a string`);
136
+ }
137
+ }
138
+ return parsed as ChangelogState;
139
+ }
140
+
141
+ async function acquireLock(path: string): Promise<() => Promise<void>> {
142
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
143
+ while (true) {
144
+ try {
145
+ await mkdir(path);
146
+ return () => rm(path, { force: true, recursive: true });
147
+ } catch (error) {
148
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
149
+ const info = await stat(path).catch(() => undefined);
150
+ if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
151
+ await rm(path, { force: true, recursive: true });
152
+ continue;
153
+ }
154
+ if (Date.now() >= deadline)
155
+ throw new Error(`Timed out waiting for ${path}`);
156
+ await new Promise((resolve) => setTimeout(resolve, 20));
157
+ }
158
+ }
159
+ }
160
+
161
+ async function writeState(path: string, state: ChangelogState): Promise<void> {
162
+ await mkdir(dirname(path), { mode: 0o700, recursive: true });
163
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
164
+ try {
165
+ const ordered = Object.fromEntries(
166
+ Object.entries(state).sort(([left], [right]) =>
167
+ left.localeCompare(right),
168
+ ),
169
+ );
170
+ await writeFile(temporaryPath, `${JSON.stringify(ordered, null, 2)}\n`, {
171
+ encoding: "utf8",
172
+ mode: 0o600,
173
+ });
174
+ await rename(temporaryPath, path);
175
+ } catch (error) {
176
+ await rm(temporaryPath, { force: true });
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ function packageMarkdown(
182
+ registration: PackageRegistration,
183
+ entries: ChangelogEntry[],
184
+ ): string {
185
+ const body = (entry: ChangelogEntry) =>
186
+ entry.content
187
+ .replace(/^##[^\n]*\n*/, "")
188
+ .replace(/^###\s+(?:Changes|Minor Changes|Patch Changes)\s*\n*/gim, "")
189
+ .replace(/^###\s+(.+)$/gm, "**$1**");
190
+ if (entries.length === 1) {
191
+ const [entry] = entries;
192
+ if (!entry) return "";
193
+ return `## ${registration.name} ${entry.version}\n\n${body(entry)}`;
194
+ }
195
+ return [
196
+ `## ${registration.name}`,
197
+ ...entries.map((entry) => `**${entry.version}**\n\n${body(entry)}`),
198
+ ].join("\n\n");
199
+ }
200
+
201
+ async function claimUpdates(
202
+ registrations: Iterable<PackageRegistration>,
203
+ ): Promise<{ errors: string[]; markdown?: string }> {
204
+ const stableRegistrations = [...registrations]
205
+ .filter((registration) => isStableVersion(registration.version))
206
+ .sort((left, right) => left.name.localeCompare(right.name));
207
+ if (stableRegistrations.length === 0) return { errors: [] };
208
+ const path = statePath();
209
+ await mkdir(dirname(path), { mode: 0o700, recursive: true });
210
+ const release = await acquireLock(`${path}.lock`);
211
+ try {
212
+ const state = await readState(path);
213
+ const errors: string[] = [];
214
+ const sections: string[] = [];
215
+ let changed = false;
216
+ if (state[SUPPRESS_KEY] === true) {
217
+ for (const registration of stableRegistrations) {
218
+ const seenVersion = state[registration.name];
219
+ if (
220
+ typeof seenVersion !== "string" ||
221
+ compareVersions(registration.version, seenVersion) > 0
222
+ ) {
223
+ state[registration.name] = registration.version;
224
+ changed = true;
225
+ }
226
+ }
227
+ if (changed) await writeState(path, state);
228
+ return { errors };
229
+ }
230
+ for (const registration of stableRegistrations) {
231
+ const seenVersion = state[registration.name];
232
+ if (
233
+ typeof seenVersion === "string" &&
234
+ compareVersions(registration.version, seenVersion) <= 0
235
+ )
236
+ continue;
237
+ try {
238
+ const entries = parseChangelog(
239
+ await readFile(registration.changelogPath, "utf8"),
240
+ ).filter(
241
+ (entry) =>
242
+ compareVersions(entry.version, registration.version) <= 0 &&
243
+ (typeof seenVersion === "string"
244
+ ? compareVersions(entry.version, seenVersion) > 0
245
+ : entry.version === registration.version),
246
+ );
247
+ if (entries.length === 0) {
248
+ errors.push(
249
+ `No ${registration.version} changelog entry found for ${registration.name}`,
250
+ );
251
+ continue;
252
+ }
253
+ sections.push(packageMarkdown(registration, entries));
254
+ state[registration.name] = registration.version;
255
+ changed = true;
256
+ } catch (error) {
257
+ errors.push(
258
+ `Could not read the ${registration.name} changelog: ${error instanceof Error ? error.message : String(error)}`,
259
+ );
260
+ }
261
+ }
262
+ if (changed) await writeState(path, state);
263
+ return {
264
+ errors,
265
+ ...(sections.length > 0 ? { markdown: sections.join("\n\n") } : {}),
266
+ };
267
+ } finally {
268
+ await release();
269
+ }
270
+ }
271
+
272
+ function registerCoordinator(
273
+ pi: ExtensionAPI,
274
+ registration: PackageRegistration,
275
+ ): void {
276
+ let accepted = false;
277
+ pi.events.emit(REGISTRATION_CHANNEL, {
278
+ registration,
279
+ accept: () => {
280
+ accepted = true;
281
+ },
282
+ } satisfies RegistrationEvent);
283
+ if (accepted) return;
284
+
285
+ const registrations = new Map([[registration.name, registration]]);
286
+ pi.events.on(REGISTRATION_CHANNEL, (value) => {
287
+ if (!isRegistrationEvent(value)) return;
288
+ registrations.set(value.registration.name, value.registration);
289
+ value.accept();
290
+ });
291
+ pi.registerEntryRenderer<ChangelogEntryData>(
292
+ ENTRY_TYPE,
293
+ (entry, _options, theme) => {
294
+ if (!entry.data) return undefined;
295
+ const container = new Container();
296
+ container.addChild(new DynamicBorder());
297
+ container.addChild(
298
+ new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0),
299
+ );
300
+ container.addChild(new Spacer(1));
301
+ container.addChild(
302
+ new Markdown(entry.data.markdown.trim(), 1, 0, getMarkdownTheme()),
303
+ );
304
+ container.addChild(new Spacer(1));
305
+ container.addChild(new DynamicBorder());
306
+ return container;
307
+ },
308
+ );
309
+ pi.on("session_start", async (event, ctx) => {
310
+ if (event.reason === "reload" || ctx.mode !== "tui") return;
311
+ if (
312
+ ctx.sessionManager
313
+ .getEntries()
314
+ .some((entry) =>
315
+ ["branch_summary", "compaction", "message"].includes(entry.type),
316
+ )
317
+ )
318
+ return;
319
+ try {
320
+ const update = await claimUpdates(registrations.values());
321
+ if (update.markdown)
322
+ pi.appendEntry<ChangelogEntryData>(ENTRY_TYPE, {
323
+ markdown: update.markdown,
324
+ });
325
+ for (const error of update.errors) ctx.ui.notify(error, "warning");
326
+ } catch (error) {
327
+ ctx.ui.notify(
328
+ `Could not update Howaboua changelog state: ${error instanceof Error ? error.message : String(error)}`,
329
+ "warning",
330
+ );
331
+ }
332
+ });
333
+ }
334
+
335
+ export default function howabouaPackageChangelog(pi: ExtensionAPI): void {
336
+ registerCoordinator(pi, packageRegistration());
337
+ }
@@ -0,0 +1,185 @@
1
+ import { statSync } from "node:fs";
2
+ import { DatabaseSync, } from "node:sqlite";
3
+ function text(row, key) {
4
+ const value = row[key];
5
+ if (typeof value !== "string")
6
+ throw new Error(`Invalid board archive field: ${key}`);
7
+ return value;
8
+ }
9
+ function count(row, key) {
10
+ const value = row[key];
11
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
12
+ throw new Error(`Invalid board archive count: ${key}`);
13
+ return value;
14
+ }
15
+ function nullableText(row, key) {
16
+ return row[key] === null ? null : text(row, key);
17
+ }
18
+ function post(row) {
19
+ return {
20
+ id: text(row, "id"),
21
+ channel: text(row, "channel"),
22
+ author: text(row, "author"),
23
+ createdAt: text(row, "created_at"),
24
+ text: text(row, "text"),
25
+ };
26
+ }
27
+ /** The archive is opened read-only; no migrations, board creation or subscriptions. */
28
+ export class Archive {
29
+ db;
30
+ source;
31
+ constructor(source) {
32
+ this.source = source;
33
+ try {
34
+ statSync(source.databasePath);
35
+ }
36
+ catch (error) {
37
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
38
+ return;
39
+ throw error;
40
+ }
41
+ const db = new DatabaseSync(source.databasePath, { readOnly: true });
42
+ try {
43
+ db.exec("PRAGMA busy_timeout=2000; PRAGMA query_only=ON; BEGIN");
44
+ if (db.prepare("PRAGMA user_version").get()?.["user_version"] !== 1)
45
+ throw new Error("Unsupported board archive version");
46
+ this.db = db;
47
+ }
48
+ catch (error) {
49
+ db.close();
50
+ throw error;
51
+ }
52
+ }
53
+ close() {
54
+ this.db?.close();
55
+ }
56
+ all(sql, ...args) {
57
+ return this.db?.prepare(sql).all(...args) ?? [];
58
+ }
59
+ index() {
60
+ const { databasePath: _path, ...context } = this.source;
61
+ const boards = this.all(`SELECT b.board_id,b.root_session_id,b.created_at,
62
+ (SELECT MAX(created_at) FROM posts WHERE board_id=b.board_id) AS last_activity,
63
+ (SELECT COUNT(*) FROM channels WHERE board_id=b.board_id) AS channels,
64
+ (SELECT COUNT(*) FROM posts WHERE board_id=b.board_id) AS posts,
65
+ (SELECT COUNT(DISTINCT author) FROM posts WHERE board_id=b.board_id) AS agents
66
+ FROM boards b WHERE owner_folder=? ORDER BY COALESCE(last_activity,b.created_at) DESC`, this.source.folder).map((row) => ({
67
+ id: text(row, "board_id"),
68
+ rootSessionId: text(row, "root_session_id"),
69
+ createdAt: text(row, "created_at"),
70
+ lastActivity: nullableText(row, "last_activity"),
71
+ channels: count(row, "channels"),
72
+ posts: count(row, "posts"),
73
+ agents: count(row, "agents"),
74
+ }));
75
+ if (!boards.some((board) => board.id === context.boardId))
76
+ boards.unshift({
77
+ id: context.boardId,
78
+ rootSessionId: context.sessionId,
79
+ createdAt: null,
80
+ lastActivity: null,
81
+ channels: 0,
82
+ posts: 0,
83
+ agents: 0,
84
+ });
85
+ return { context, boards };
86
+ }
87
+ board(id) {
88
+ const board = this.index().boards.find((board) => board.id === id);
89
+ if (!board)
90
+ throw new Error("Board not found in this folder");
91
+ return board;
92
+ }
93
+ detail(id) {
94
+ const board = this.board(id);
95
+ const channels = this.all(`SELECT c.name,c.author,c.created_at,COUNT(p.id) AS posts,
96
+ COUNT(CASE WHEN p.id=p.root THEN 1 END) AS threads,MAX(p.created_at) AS last_activity
97
+ FROM channels c LEFT JOIN posts p ON p.board_id=c.board_id AND p.channel=c.name
98
+ WHERE c.board_id=? GROUP BY c.name ORDER BY COALESCE(last_activity,c.created_at) DESC,c.name`, id).map((row) => ({
99
+ name: text(row, "name"),
100
+ author: text(row, "author"),
101
+ createdAt: text(row, "created_at"),
102
+ posts: count(row, "posts"),
103
+ threads: count(row, "threads"),
104
+ lastActivity: nullableText(row, "last_activity"),
105
+ }));
106
+ const agents = this.all("SELECT author,COUNT(*) AS posts,MAX(created_at) AS last_activity FROM posts WHERE board_id=? GROUP BY author ORDER BY posts DESC,author", id).map((row) => ({
107
+ name: text(row, "author"),
108
+ posts: count(row, "posts"),
109
+ lastActivity: text(row, "last_activity"),
110
+ }));
111
+ const subscriptions = this.all("SELECT target,agent,enabled FROM subscriptions WHERE board_id=? ORDER BY target,agent", id).map((row) => ({
112
+ target: text(row, "target"),
113
+ agent: text(row, "agent"),
114
+ enabled: count(row, "enabled") === 1,
115
+ }));
116
+ return { board, channels, agents, subscriptions };
117
+ }
118
+ threads(id, params) {
119
+ this.board(id);
120
+ const channel = params.get("channel");
121
+ const query = params.get("q");
122
+ const author = params.get("author");
123
+ if (query && query.length > 1000)
124
+ throw new Error("Search is too long");
125
+ const where = ["r.board_id=?", "r.id=r.root"];
126
+ const args = [id];
127
+ if (channel !== null) {
128
+ where.push("r.channel=?");
129
+ args.push(channel);
130
+ }
131
+ if (query || author) {
132
+ const match = ["m.board_id=r.board_id", "m.root=r.id"];
133
+ if (query) {
134
+ match.push("instr(lower(m.text),lower(?))>0");
135
+ args.push(query);
136
+ }
137
+ if (author) {
138
+ match.push("m.author=?");
139
+ args.push(author);
140
+ }
141
+ where.push(`EXISTS(SELECT 1 FROM posts m WHERE ${match.join(" AND ")})`);
142
+ }
143
+ const cursor = this.cursor(params);
144
+ const rows = this.all(`SELECT r.id,r.channel,r.author,r.text,r.created_at,
145
+ MAX(p.created_at) AS last_activity,MAX(p.seq) AS last_seq,COUNT(*)-1 AS replies
146
+ FROM posts r JOIN posts p ON p.board_id=r.board_id AND p.root=r.id
147
+ WHERE ${where.join(" AND ")} GROUP BY r.id HAVING MAX(p.seq)<? ORDER BY last_seq DESC LIMIT 41`, ...args, cursor ?? Number.MAX_SAFE_INTEGER);
148
+ const visible = rows.slice(0, 40);
149
+ const last = visible.at(-1);
150
+ return {
151
+ threads: visible.map((row) => ({
152
+ id: text(row, "id"),
153
+ channel: text(row, "channel"),
154
+ author: text(row, "author"),
155
+ preview: text(row, "text").slice(0, 600),
156
+ createdAt: text(row, "created_at"),
157
+ lastActivity: text(row, "last_activity"),
158
+ replies: count(row, "replies"),
159
+ })),
160
+ nextCursor: rows.length > 40 && last ? String(count(last, "last_seq")) : null,
161
+ };
162
+ }
163
+ thread(id, threadId, params) {
164
+ this.board(id);
165
+ const root = this.all("SELECT id,channel,author,created_at,text FROM posts WHERE board_id=? AND id=? AND root=id", id, threadId)[0];
166
+ if (!root)
167
+ throw new Error("Thread not found in this board");
168
+ const rows = this.all("SELECT seq,id,channel,author,created_at,text FROM posts WHERE board_id=? AND root=? AND id<>root AND seq>? ORDER BY seq LIMIT 81", id, threadId, this.cursor(params) ?? 0);
169
+ const visible = rows.slice(0, 80);
170
+ const last = visible.at(-1);
171
+ return {
172
+ root: post(root),
173
+ replies: visible.map(post),
174
+ nextCursor: rows.length > 80 && last ? String(count(last, "seq")) : null,
175
+ };
176
+ }
177
+ cursor(params) {
178
+ const value = params.get("cursor");
179
+ if (value === null)
180
+ return undefined;
181
+ if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)))
182
+ throw new Error("Invalid page cursor");
183
+ return Number(value);
184
+ }
185
+ }