@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 +15 -0
- package/INTEGRATION.md +31 -0
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/changelog.ts +337 -0
- package/dist/viewer/archive.js +185 -0
- package/dist/viewer/config.js +85 -0
- package/dist/viewer/contracts.js +1 -0
- package/dist/viewer/server.js +172 -0
- package/dist/viewer/source.js +29 -0
- package/dist/viewer/web/app.js +485 -0
- package/dist/viewer/web/index.html +39 -0
- package/dist/viewer/web/style.css +481 -0
- package/index.ts +63 -0
- package/package.json +65 -5
- package/src/activity.ts +273 -0
- package/src/archive.ts +103 -0
- package/src/awareness.ts +181 -0
- package/src/briefing.ts +19 -0
- package/src/config.ts +104 -0
- package/src/context-briefing.ts +227 -0
- package/src/contract.ts +189 -0
- package/src/delivery.ts +76 -0
- package/src/identity.ts +174 -0
- package/src/integration.ts +54 -0
- package/src/paging.ts +55 -0
- package/src/protocol.ts +78 -0
- package/src/queries.ts +176 -0
- package/src/response.ts +56 -0
- package/src/runtime.ts +561 -0
- package/src/store.ts +327 -0
- package/src/tool.ts +22 -0
- package/src/turns.ts +101 -0
- package/src/viewer/archive.ts +227 -0
- package/src/viewer/config.ts +111 -0
- package/src/viewer/contracts.ts +82 -0
- package/src/viewer/index.ts +158 -0
- package/src/viewer/server.ts +199 -0
- package/src/viewer/source.ts +33 -0
- package/src/viewer/web/app.js +485 -0
- package/src/viewer/web/index.html +39 -0
- package/src/viewer/web/style.css +481 -0
- package/tsconfig.json +5 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
5
|
+
import { caseFold } from "unicode-case-folding";
|
|
6
|
+
import {
|
|
7
|
+
agentPath,
|
|
8
|
+
type BoardParams,
|
|
9
|
+
MUTATIONS,
|
|
10
|
+
required,
|
|
11
|
+
} from "./contract.js";
|
|
12
|
+
import { metadata, POST_COLUMNS, type PostRecord, preview } from "./paging.js";
|
|
13
|
+
import { queryBoard } from "./queries.js";
|
|
14
|
+
import { serializeBoardResult } from "./response.js";
|
|
15
|
+
|
|
16
|
+
export interface BoardScope {
|
|
17
|
+
boardId: string;
|
|
18
|
+
rootSessionId: string;
|
|
19
|
+
ownerFolder: string;
|
|
20
|
+
agentName: string;
|
|
21
|
+
callerSessionId: string;
|
|
22
|
+
members: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
const SCHEMA = `
|
|
25
|
+
CREATE TABLE boards (board_id TEXT PRIMARY KEY, root_session_id TEXT NOT NULL, owner_folder TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
26
|
+
CREATE TABLE channels (board_id TEXT NOT NULL, name TEXT NOT NULL, name_search TEXT NOT NULL, created_at TEXT NOT NULL, author TEXT NOT NULL, PRIMARY KEY(board_id,name));
|
|
27
|
+
CREATE TABLE posts (seq INTEGER PRIMARY KEY AUTOINCREMENT,board_id TEXT NOT NULL,id TEXT NOT NULL,channel TEXT NOT NULL,root TEXT NOT NULL,author TEXT NOT NULL,created_at TEXT NOT NULL,text TEXT NOT NULL,body_search TEXT NOT NULL,request_id TEXT NOT NULL,request TEXT NOT NULL,UNIQUE(board_id,id),UNIQUE(board_id,request_id));
|
|
28
|
+
CREATE INDEX posts_channel ON posts(board_id,channel,created_at,seq);
|
|
29
|
+
CREATE INDEX posts_root ON posts(board_id,root,created_at,seq);
|
|
30
|
+
CREATE INDEX posts_order ON posts(board_id,created_at,seq);
|
|
31
|
+
CREATE TABLE subscriptions (board_id TEXT NOT NULL,target TEXT NOT NULL,agent TEXT NOT NULL,enabled INTEGER NOT NULL,PRIMARY KEY(board_id,target,agent));
|
|
32
|
+
PRAGMA user_version=1;`;
|
|
33
|
+
|
|
34
|
+
/** One transaction/connection per call. Reads never create a file, schema or board. */
|
|
35
|
+
export class BoardStore {
|
|
36
|
+
readonly db: DatabaseSync;
|
|
37
|
+
constructor(path: string, write: boolean) {
|
|
38
|
+
const fresh = !existsSync(path);
|
|
39
|
+
if (write && fresh) mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
this.db = new DatabaseSync(path, { readOnly: !write });
|
|
41
|
+
try {
|
|
42
|
+
this.db.exec("PRAGMA busy_timeout=5000");
|
|
43
|
+
if (write) {
|
|
44
|
+
if (fresh) chmodSync(path, 0o600);
|
|
45
|
+
this.db.exec("PRAGMA journal_mode=WAL; BEGIN IMMEDIATE");
|
|
46
|
+
}
|
|
47
|
+
const version = this.one<{ user_version: number }>(
|
|
48
|
+
"PRAGMA user_version",
|
|
49
|
+
)?.user_version;
|
|
50
|
+
if (
|
|
51
|
+
version === 0 &&
|
|
52
|
+
write &&
|
|
53
|
+
this.all("SELECT name FROM sqlite_master WHERE type='table'").length ===
|
|
54
|
+
0
|
|
55
|
+
)
|
|
56
|
+
this.db.exec(SCHEMA);
|
|
57
|
+
else if (version !== 1)
|
|
58
|
+
throw new Error("Unsupported Shepherdr board archive schema");
|
|
59
|
+
if (write) this.db.exec("COMMIT");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.db.close();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
one<T>(sql: string, ...args: SQLInputValue[]): T | undefined {
|
|
66
|
+
return this.db.prepare(sql).get(...args) as T | undefined;
|
|
67
|
+
}
|
|
68
|
+
all<T>(sql: string, ...args: SQLInputValue[]): T[] {
|
|
69
|
+
return this.db.prepare(sql).all(...args) as T[];
|
|
70
|
+
}
|
|
71
|
+
run(sql: string, ...args: SQLInputValue[]) {
|
|
72
|
+
return this.db.prepare(sql).run(...args);
|
|
73
|
+
}
|
|
74
|
+
post(board: string, id: string): PostRecord {
|
|
75
|
+
const post = this.one<PostRecord>(
|
|
76
|
+
`SELECT ${POST_COLUMNS} FROM posts WHERE board_id=? AND id=?`,
|
|
77
|
+
board,
|
|
78
|
+
id,
|
|
79
|
+
);
|
|
80
|
+
if (!post) throw new Error("Post not found in this board");
|
|
81
|
+
return post;
|
|
82
|
+
}
|
|
83
|
+
root(board: string, id: string): PostRecord {
|
|
84
|
+
const post = this.post(board, id);
|
|
85
|
+
if (post.thread_id !== id)
|
|
86
|
+
throw new Error("thread_id must identify a first post");
|
|
87
|
+
return post;
|
|
88
|
+
}
|
|
89
|
+
channel(board: string, name: string) {
|
|
90
|
+
const channel = this.one<{
|
|
91
|
+
channel_name: string;
|
|
92
|
+
created_at: string;
|
|
93
|
+
created_by: string;
|
|
94
|
+
message_count: number;
|
|
95
|
+
last_message_id: string | null;
|
|
96
|
+
}>(
|
|
97
|
+
`SELECT name AS channel_name,created_at,author AS created_by,
|
|
98
|
+
(SELECT COUNT(*) FROM posts p WHERE p.board_id=c.board_id AND p.channel=c.name) AS message_count,
|
|
99
|
+
(SELECT id FROM posts p WHERE p.board_id=c.board_id AND p.channel=c.name ORDER BY created_at DESC,seq DESC LIMIT 1) AS last_message_id
|
|
100
|
+
FROM channels c WHERE board_id=? AND name=?`,
|
|
101
|
+
board,
|
|
102
|
+
name,
|
|
103
|
+
);
|
|
104
|
+
if (!channel) throw new Error("Channel not found in this board");
|
|
105
|
+
return channel;
|
|
106
|
+
}
|
|
107
|
+
execute(scope: BoardScope, params: BoardParams, requestId: string) {
|
|
108
|
+
const write = MUTATIONS.has(params.action);
|
|
109
|
+
this.db.exec(write ? "BEGIN IMMEDIATE" : "BEGIN");
|
|
110
|
+
try {
|
|
111
|
+
const result = write
|
|
112
|
+
? this.mutate(scope, params, requestId)
|
|
113
|
+
: { value: queryBoard(this, scope.boardId, params), recipients: [] };
|
|
114
|
+
if (write) serializeBoardResult(result.value);
|
|
115
|
+
this.db.exec("COMMIT");
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
this.db.exec("ROLLBACK");
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
close() {
|
|
123
|
+
this.db.close();
|
|
124
|
+
}
|
|
125
|
+
private ensureBoard(scope: BoardScope, now: string) {
|
|
126
|
+
this.run(
|
|
127
|
+
"INSERT OR IGNORE INTO boards VALUES(?,?,?,?)",
|
|
128
|
+
scope.boardId,
|
|
129
|
+
scope.rootSessionId,
|
|
130
|
+
scope.ownerFolder,
|
|
131
|
+
now,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
private insertChannel(scope: BoardScope, name: string, now: string) {
|
|
135
|
+
if (
|
|
136
|
+
this.one(
|
|
137
|
+
"SELECT 1 FROM channels WHERE board_id=? AND name=?",
|
|
138
|
+
scope.boardId,
|
|
139
|
+
name,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
throw new Error("Channel already exists");
|
|
143
|
+
this.run(
|
|
144
|
+
"INSERT INTO channels VALUES(?,?,?,?,?)",
|
|
145
|
+
scope.boardId,
|
|
146
|
+
name,
|
|
147
|
+
caseFold(name),
|
|
148
|
+
now,
|
|
149
|
+
scope.agentName,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
private subscription(
|
|
153
|
+
board: string,
|
|
154
|
+
target: string,
|
|
155
|
+
agent: string,
|
|
156
|
+
enabled: boolean,
|
|
157
|
+
implicit = false,
|
|
158
|
+
) {
|
|
159
|
+
this.run(
|
|
160
|
+
implicit
|
|
161
|
+
? "INSERT OR IGNORE INTO subscriptions VALUES(?,?,?,1)"
|
|
162
|
+
: "INSERT INTO subscriptions VALUES(?,?,?,?) ON CONFLICT(board_id,target,agent) DO UPDATE SET enabled=excluded.enabled",
|
|
163
|
+
...[board, target, agent, ...(implicit ? [] : [enabled ? 1 : 0])],
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
private resolve(scope: BoardScope, value: string) {
|
|
167
|
+
const path = agentPath(value, scope.agentName);
|
|
168
|
+
if (!scope.members.includes(path))
|
|
169
|
+
throw new Error(`Agent not bound to this tree: ${path}`);
|
|
170
|
+
return path;
|
|
171
|
+
}
|
|
172
|
+
private mutate(scope: BoardScope, params: BoardParams, requestId: string) {
|
|
173
|
+
const board = scope.boardId;
|
|
174
|
+
if (params.action === "post") return this.publish(scope, params, requestId);
|
|
175
|
+
if (params.action === "create_channel") {
|
|
176
|
+
const name = required(params.channel_name, "channel_name");
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
this.ensureBoard(scope, now);
|
|
179
|
+
this.insertChannel(scope, name, now);
|
|
180
|
+
if (params.subscribe !== false)
|
|
181
|
+
this.subscription(board, `channel:${name}`, scope.agentName, true);
|
|
182
|
+
return { value: this.channel(board, name), recipients: [] };
|
|
183
|
+
}
|
|
184
|
+
const target_agent =
|
|
185
|
+
params.target_agent === undefined
|
|
186
|
+
? scope.agentName
|
|
187
|
+
: this.resolve(scope, params.target_agent);
|
|
188
|
+
const root =
|
|
189
|
+
params.thread_id === undefined
|
|
190
|
+
? undefined
|
|
191
|
+
: this.root(board, params.thread_id);
|
|
192
|
+
const channel = this.channel(
|
|
193
|
+
board,
|
|
194
|
+
root?.channel_name ?? required(params.channel_name, "channel_name"),
|
|
195
|
+
);
|
|
196
|
+
const thread_id = root?.thread_id ?? null;
|
|
197
|
+
const target = root
|
|
198
|
+
? `thread:${root.thread_id}`
|
|
199
|
+
: `channel:${channel.channel_name}`;
|
|
200
|
+
const last = root
|
|
201
|
+
? (this.one<{ id: string }>(
|
|
202
|
+
"SELECT id FROM posts WHERE board_id=? AND root=? ORDER BY created_at DESC,seq DESC LIMIT 1",
|
|
203
|
+
board,
|
|
204
|
+
root.thread_id,
|
|
205
|
+
)?.id ?? null)
|
|
206
|
+
: channel.last_message_id;
|
|
207
|
+
const enabled = params.action === "subscribe";
|
|
208
|
+
this.subscription(board, target, target_agent, enabled);
|
|
209
|
+
return {
|
|
210
|
+
value: {
|
|
211
|
+
channel_name: channel.channel_name,
|
|
212
|
+
thread_id,
|
|
213
|
+
target_agent,
|
|
214
|
+
enabled,
|
|
215
|
+
last_message_id: last,
|
|
216
|
+
},
|
|
217
|
+
recipients: [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
private publish(scope: BoardScope, params: BoardParams, requestId: string) {
|
|
221
|
+
const board = scope.boardId;
|
|
222
|
+
const request = JSON.stringify(
|
|
223
|
+
Object.fromEntries(
|
|
224
|
+
Object.entries({
|
|
225
|
+
...params,
|
|
226
|
+
agents_to_notify: params.agents_to_notify ?? [],
|
|
227
|
+
}).sort(([a], [b]) => a.localeCompare(b)),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
const key = `${scope.callerSessionId}:${requestId}`;
|
|
231
|
+
if (!requestId || Buffer.byteLength(key) > 1024)
|
|
232
|
+
throw new Error("Invalid post invocation ID");
|
|
233
|
+
const previous = this.one<{ id: string; request: string }>(
|
|
234
|
+
"SELECT id,request FROM posts WHERE board_id=? AND request_id=?",
|
|
235
|
+
board,
|
|
236
|
+
key,
|
|
237
|
+
);
|
|
238
|
+
if (previous) {
|
|
239
|
+
if (previous.request !== request)
|
|
240
|
+
throw new Error("Invocation ID already used for a different post");
|
|
241
|
+
return { value: metadata(this.post(board, previous.id)), recipients: [] };
|
|
242
|
+
}
|
|
243
|
+
const recipients = new Set(
|
|
244
|
+
(params.agents_to_notify ?? []).map((name) => this.resolve(scope, name)),
|
|
245
|
+
);
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
const id = randomUUID();
|
|
248
|
+
let channel: string;
|
|
249
|
+
let root: string = id;
|
|
250
|
+
if (params.thread_id !== undefined) {
|
|
251
|
+
const post = this.root(board, params.thread_id);
|
|
252
|
+
channel = post.channel_name;
|
|
253
|
+
root = post.thread_id;
|
|
254
|
+
} else if (params.new_channel_name !== undefined) {
|
|
255
|
+
channel = params.new_channel_name;
|
|
256
|
+
this.ensureBoard(scope, now);
|
|
257
|
+
this.insertChannel(scope, channel, now);
|
|
258
|
+
this.subscription(board, `channel:${channel}`, scope.agentName, true);
|
|
259
|
+
} else
|
|
260
|
+
channel = this.channel(
|
|
261
|
+
board,
|
|
262
|
+
required(params.channel_name, "channel_name"),
|
|
263
|
+
).channel_name;
|
|
264
|
+
const target = root === id ? `channel:${channel}` : `thread:${root}`;
|
|
265
|
+
for (const { agent } of this.all<{ agent: string }>(
|
|
266
|
+
"SELECT agent FROM subscriptions WHERE board_id=? AND target=? AND enabled=1",
|
|
267
|
+
board,
|
|
268
|
+
target,
|
|
269
|
+
))
|
|
270
|
+
recipients.add(agent);
|
|
271
|
+
recipients.delete(scope.agentName);
|
|
272
|
+
const post: PostRecord = {
|
|
273
|
+
message_id: id,
|
|
274
|
+
channel_name: channel,
|
|
275
|
+
author: scope.agentName,
|
|
276
|
+
thread_id: root,
|
|
277
|
+
created_at: now,
|
|
278
|
+
text: required(params.text, "text"),
|
|
279
|
+
};
|
|
280
|
+
this.run(
|
|
281
|
+
"INSERT INTO posts(board_id,id,channel,root,author,created_at,text,body_search,request_id,request) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
282
|
+
board,
|
|
283
|
+
id,
|
|
284
|
+
channel,
|
|
285
|
+
root,
|
|
286
|
+
scope.agentName,
|
|
287
|
+
now,
|
|
288
|
+
post.text,
|
|
289
|
+
caseFold(post.text),
|
|
290
|
+
key,
|
|
291
|
+
request,
|
|
292
|
+
);
|
|
293
|
+
this.subscription(board, `thread:${root}`, scope.agentName, true, true);
|
|
294
|
+
return {
|
|
295
|
+
value: metadata(post),
|
|
296
|
+
notice: preview(post, 150),
|
|
297
|
+
recipients: [...recipients],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function validateMutation(params: BoardParams) {
|
|
303
|
+
if (
|
|
304
|
+
params.action === "create_channel" ||
|
|
305
|
+
params.new_channel_name !== undefined
|
|
306
|
+
) {
|
|
307
|
+
const name = required(
|
|
308
|
+
params.new_channel_name ?? params.channel_name,
|
|
309
|
+
"channel_name",
|
|
310
|
+
);
|
|
311
|
+
if (
|
|
312
|
+
!name ||
|
|
313
|
+
!name.isWellFormed() ||
|
|
314
|
+
Buffer.byteLength(name) > 128 ||
|
|
315
|
+
name.trim() !== name ||
|
|
316
|
+
/\p{Cc}/u.test(name)
|
|
317
|
+
)
|
|
318
|
+
throw new Error(
|
|
319
|
+
"Channel must be 1-128 UTF-8 bytes without edge whitespace/control characters",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (params.action === "post") {
|
|
323
|
+
const text = required(params.text, "text");
|
|
324
|
+
if (!text || !text.isWellFormed() || Buffer.byteLength(text) > 65536)
|
|
325
|
+
throw new Error("Post must be 1-65536 UTF-8 bytes");
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { BoardParameters } from "./contract.js";
|
|
3
|
+
import { serializeBoardResult } from "./response.js";
|
|
4
|
+
import type { BoardRuntime } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
export function createBoardTool(board: BoardRuntime) {
|
|
7
|
+
return defineTool({
|
|
8
|
+
name: "board",
|
|
9
|
+
label: "Message board",
|
|
10
|
+
description: "Shared discussions and saved board history",
|
|
11
|
+
parameters: BoardParameters,
|
|
12
|
+
executionMode: "sequential",
|
|
13
|
+
async execute(id, params, signal, _onUpdate, ctx) {
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
const value = await board.execute(ctx, params, id);
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: serializeBoardResult(value) }],
|
|
18
|
+
details: {},
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
package/src/turns.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { sendPolicyMessage } from "./delivery.js";
|
|
7
|
+
import { type BoardBinding, binding } from "./identity.js";
|
|
8
|
+
import type { BoardEnvelope } from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
type ActiveEnvelope = Extract<BoardEnvelope, { operation: "board-active" }>;
|
|
11
|
+
type NoticeEnvelope = Extract<BoardEnvelope, { operation: "board-notify" }>;
|
|
12
|
+
|
|
13
|
+
/** Turn IDs are transient capabilities. A stale snapshot cannot reach a later turn. */
|
|
14
|
+
export class BoardTurns {
|
|
15
|
+
private current: { ctx: ExtensionContext; turnId: string } | undefined;
|
|
16
|
+
readonly active = new Map<string, { sessionId: string; turnId: string }>();
|
|
17
|
+
constructor(
|
|
18
|
+
pi: ExtensionAPI,
|
|
19
|
+
register: (
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
request: ActiveEnvelope,
|
|
22
|
+
) => Promise<unknown>,
|
|
23
|
+
availability: (ctx: ExtensionContext, error?: unknown) => void,
|
|
24
|
+
seen: (ctx: ExtensionContext, ids: string[]) => void,
|
|
25
|
+
) {
|
|
26
|
+
pi.on("session_start", () => {
|
|
27
|
+
this.current = undefined;
|
|
28
|
+
this.active.clear();
|
|
29
|
+
});
|
|
30
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
31
|
+
const own = binding(ctx);
|
|
32
|
+
if (!own.enabled) return;
|
|
33
|
+
const turnId = randomUUID();
|
|
34
|
+
this.current = { ctx, turnId };
|
|
35
|
+
try {
|
|
36
|
+
await register(ctx, { operation: "board-active", caller: own, turnId });
|
|
37
|
+
availability(ctx);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
availability(ctx, error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const stop = async () => {
|
|
43
|
+
const current = this.current;
|
|
44
|
+
this.current = undefined;
|
|
45
|
+
if (!current) return;
|
|
46
|
+
try {
|
|
47
|
+
await register(current.ctx, {
|
|
48
|
+
operation: "board-active",
|
|
49
|
+
caller: binding(current.ctx),
|
|
50
|
+
turnId: null,
|
|
51
|
+
previousTurnId: current.turnId,
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
/* No retry or delayed removal may affect a later turn. */
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
pi.on("agent_end", stop);
|
|
58
|
+
pi.on("agent_settled", stop);
|
|
59
|
+
pi.on("session_shutdown", async () => {
|
|
60
|
+
await stop();
|
|
61
|
+
this.active.clear();
|
|
62
|
+
});
|
|
63
|
+
this.deliver = (request) => {
|
|
64
|
+
const current = this.current;
|
|
65
|
+
if (!current || current.turnId !== request.turnId || current.ctx.isIdle())
|
|
66
|
+
return false;
|
|
67
|
+
const own = binding(current.ctx);
|
|
68
|
+
if (
|
|
69
|
+
!own.enabled ||
|
|
70
|
+
own.boardId !== request.boardId ||
|
|
71
|
+
own.sessionId !== request.sessionId ||
|
|
72
|
+
own.agentName !== request.target
|
|
73
|
+
)
|
|
74
|
+
return false;
|
|
75
|
+
// No await between running-turn check and steering. Never use the idle kickoff.
|
|
76
|
+
sendPolicyMessage(
|
|
77
|
+
pi,
|
|
78
|
+
{
|
|
79
|
+
customType: "shepherdr-board-post",
|
|
80
|
+
content: `Board post ${JSON.stringify(request.notice)}`,
|
|
81
|
+
display: true,
|
|
82
|
+
},
|
|
83
|
+
{ deliverAs: "steer" },
|
|
84
|
+
);
|
|
85
|
+
seen(current.ctx, [request.notice.message_id]);
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
readonly deliver: (request: NoticeEnvelope) => boolean;
|
|
90
|
+
register(caller: BoardBinding, request: ActiveEnvelope) {
|
|
91
|
+
if (request.turnId)
|
|
92
|
+
this.active.set(caller.agentName, {
|
|
93
|
+
sessionId: caller.sessionId,
|
|
94
|
+
turnId: request.turnId,
|
|
95
|
+
});
|
|
96
|
+
else if (
|
|
97
|
+
this.active.get(caller.agentName)?.turnId === request.previousTurnId
|
|
98
|
+
)
|
|
99
|
+
this.active.delete(caller.agentName);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
DatabaseSync,
|
|
4
|
+
type SQLInputValue,
|
|
5
|
+
type SQLOutputValue,
|
|
6
|
+
} from "node:sqlite";
|
|
7
|
+
import type {
|
|
8
|
+
BoardDetail,
|
|
9
|
+
BoardIndex,
|
|
10
|
+
BoardSummary,
|
|
11
|
+
Post,
|
|
12
|
+
ThreadDetail,
|
|
13
|
+
ThreadPage,
|
|
14
|
+
ViewSource,
|
|
15
|
+
} from "./contracts.ts";
|
|
16
|
+
|
|
17
|
+
type Row = Record<string, SQLOutputValue>;
|
|
18
|
+
function text(row: Row, key: string): string {
|
|
19
|
+
const value = row[key];
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
throw new Error(`Invalid board archive field: ${key}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function count(row: Row, key: string): number {
|
|
25
|
+
const value = row[key];
|
|
26
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
27
|
+
throw new Error(`Invalid board archive count: ${key}`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function nullableText(row: Row, key: string): string | null {
|
|
31
|
+
return row[key] === null ? null : text(row, key);
|
|
32
|
+
}
|
|
33
|
+
function post(row: Row): Post {
|
|
34
|
+
return {
|
|
35
|
+
id: text(row, "id"),
|
|
36
|
+
channel: text(row, "channel"),
|
|
37
|
+
author: text(row, "author"),
|
|
38
|
+
createdAt: text(row, "created_at"),
|
|
39
|
+
text: text(row, "text"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The archive is opened read-only; no migrations, board creation or subscriptions. */
|
|
44
|
+
export class Archive {
|
|
45
|
+
private readonly db: DatabaseSync | undefined;
|
|
46
|
+
private readonly source: ViewSource;
|
|
47
|
+
constructor(source: ViewSource) {
|
|
48
|
+
this.source = source;
|
|
49
|
+
try {
|
|
50
|
+
statSync(source.databasePath);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
53
|
+
return;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const db = new DatabaseSync(source.databasePath, { readOnly: true });
|
|
57
|
+
try {
|
|
58
|
+
db.exec("PRAGMA busy_timeout=2000; PRAGMA query_only=ON; BEGIN");
|
|
59
|
+
if (db.prepare("PRAGMA user_version").get()?.["user_version"] !== 1)
|
|
60
|
+
throw new Error("Unsupported board archive version");
|
|
61
|
+
this.db = db;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
db.close();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
close(): void {
|
|
68
|
+
this.db?.close();
|
|
69
|
+
}
|
|
70
|
+
private all(sql: string, ...args: SQLInputValue[]): Row[] {
|
|
71
|
+
return this.db?.prepare(sql).all(...args) ?? [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
index(): BoardIndex {
|
|
75
|
+
const { databasePath: _path, ...context } = this.source;
|
|
76
|
+
const boards: BoardSummary[] = this.all(
|
|
77
|
+
`SELECT b.board_id,b.root_session_id,b.created_at,
|
|
78
|
+
(SELECT MAX(created_at) FROM posts WHERE board_id=b.board_id) AS last_activity,
|
|
79
|
+
(SELECT COUNT(*) FROM channels WHERE board_id=b.board_id) AS channels,
|
|
80
|
+
(SELECT COUNT(*) FROM posts WHERE board_id=b.board_id) AS posts,
|
|
81
|
+
(SELECT COUNT(DISTINCT author) FROM posts WHERE board_id=b.board_id) AS agents
|
|
82
|
+
FROM boards b WHERE owner_folder=? ORDER BY COALESCE(last_activity,b.created_at) DESC`,
|
|
83
|
+
this.source.folder,
|
|
84
|
+
).map((row) => ({
|
|
85
|
+
id: text(row, "board_id"),
|
|
86
|
+
rootSessionId: text(row, "root_session_id"),
|
|
87
|
+
createdAt: text(row, "created_at"),
|
|
88
|
+
lastActivity: nullableText(row, "last_activity"),
|
|
89
|
+
channels: count(row, "channels"),
|
|
90
|
+
posts: count(row, "posts"),
|
|
91
|
+
agents: count(row, "agents"),
|
|
92
|
+
}));
|
|
93
|
+
if (!boards.some((board) => board.id === context.boardId))
|
|
94
|
+
boards.unshift({
|
|
95
|
+
id: context.boardId,
|
|
96
|
+
rootSessionId: context.sessionId,
|
|
97
|
+
createdAt: null,
|
|
98
|
+
lastActivity: null,
|
|
99
|
+
channels: 0,
|
|
100
|
+
posts: 0,
|
|
101
|
+
agents: 0,
|
|
102
|
+
});
|
|
103
|
+
return { context, boards };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private board(id: string): BoardSummary {
|
|
107
|
+
const board = this.index().boards.find((board) => board.id === id);
|
|
108
|
+
if (!board) throw new Error("Board not found in this folder");
|
|
109
|
+
return board;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
detail(id: string): BoardDetail {
|
|
113
|
+
const board = this.board(id);
|
|
114
|
+
const channels = this.all(
|
|
115
|
+
`SELECT c.name,c.author,c.created_at,COUNT(p.id) AS posts,
|
|
116
|
+
COUNT(CASE WHEN p.id=p.root THEN 1 END) AS threads,MAX(p.created_at) AS last_activity
|
|
117
|
+
FROM channels c LEFT JOIN posts p ON p.board_id=c.board_id AND p.channel=c.name
|
|
118
|
+
WHERE c.board_id=? GROUP BY c.name ORDER BY COALESCE(last_activity,c.created_at) DESC,c.name`,
|
|
119
|
+
id,
|
|
120
|
+
).map((row) => ({
|
|
121
|
+
name: text(row, "name"),
|
|
122
|
+
author: text(row, "author"),
|
|
123
|
+
createdAt: text(row, "created_at"),
|
|
124
|
+
posts: count(row, "posts"),
|
|
125
|
+
threads: count(row, "threads"),
|
|
126
|
+
lastActivity: nullableText(row, "last_activity"),
|
|
127
|
+
}));
|
|
128
|
+
const agents = this.all(
|
|
129
|
+
"SELECT author,COUNT(*) AS posts,MAX(created_at) AS last_activity FROM posts WHERE board_id=? GROUP BY author ORDER BY posts DESC,author",
|
|
130
|
+
id,
|
|
131
|
+
).map((row) => ({
|
|
132
|
+
name: text(row, "author"),
|
|
133
|
+
posts: count(row, "posts"),
|
|
134
|
+
lastActivity: text(row, "last_activity"),
|
|
135
|
+
}));
|
|
136
|
+
const subscriptions = this.all(
|
|
137
|
+
"SELECT target,agent,enabled FROM subscriptions WHERE board_id=? ORDER BY target,agent",
|
|
138
|
+
id,
|
|
139
|
+
).map((row) => ({
|
|
140
|
+
target: text(row, "target"),
|
|
141
|
+
agent: text(row, "agent"),
|
|
142
|
+
enabled: count(row, "enabled") === 1,
|
|
143
|
+
}));
|
|
144
|
+
return { board, channels, agents, subscriptions };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
threads(id: string, params: URLSearchParams): ThreadPage {
|
|
148
|
+
this.board(id);
|
|
149
|
+
const channel = params.get("channel");
|
|
150
|
+
const query = params.get("q");
|
|
151
|
+
const author = params.get("author");
|
|
152
|
+
if (query && query.length > 1000) throw new Error("Search is too long");
|
|
153
|
+
const where = ["r.board_id=?", "r.id=r.root"];
|
|
154
|
+
const args: SQLInputValue[] = [id];
|
|
155
|
+
if (channel !== null) {
|
|
156
|
+
where.push("r.channel=?");
|
|
157
|
+
args.push(channel);
|
|
158
|
+
}
|
|
159
|
+
if (query || author) {
|
|
160
|
+
const match = ["m.board_id=r.board_id", "m.root=r.id"];
|
|
161
|
+
if (query) {
|
|
162
|
+
match.push("instr(lower(m.text),lower(?))>0");
|
|
163
|
+
args.push(query);
|
|
164
|
+
}
|
|
165
|
+
if (author) {
|
|
166
|
+
match.push("m.author=?");
|
|
167
|
+
args.push(author);
|
|
168
|
+
}
|
|
169
|
+
where.push(`EXISTS(SELECT 1 FROM posts m WHERE ${match.join(" AND ")})`);
|
|
170
|
+
}
|
|
171
|
+
const cursor = this.cursor(params);
|
|
172
|
+
const rows = this.all(
|
|
173
|
+
`SELECT r.id,r.channel,r.author,r.text,r.created_at,
|
|
174
|
+
MAX(p.created_at) AS last_activity,MAX(p.seq) AS last_seq,COUNT(*)-1 AS replies
|
|
175
|
+
FROM posts r JOIN posts p ON p.board_id=r.board_id AND p.root=r.id
|
|
176
|
+
WHERE ${where.join(" AND ")} GROUP BY r.id HAVING MAX(p.seq)<? ORDER BY last_seq DESC LIMIT 41`,
|
|
177
|
+
...args,
|
|
178
|
+
cursor ?? Number.MAX_SAFE_INTEGER,
|
|
179
|
+
);
|
|
180
|
+
const visible = rows.slice(0, 40);
|
|
181
|
+
const last = visible.at(-1);
|
|
182
|
+
return {
|
|
183
|
+
threads: visible.map((row) => ({
|
|
184
|
+
id: text(row, "id"),
|
|
185
|
+
channel: text(row, "channel"),
|
|
186
|
+
author: text(row, "author"),
|
|
187
|
+
preview: text(row, "text").slice(0, 600),
|
|
188
|
+
createdAt: text(row, "created_at"),
|
|
189
|
+
lastActivity: text(row, "last_activity"),
|
|
190
|
+
replies: count(row, "replies"),
|
|
191
|
+
})),
|
|
192
|
+
nextCursor:
|
|
193
|
+
rows.length > 40 && last ? String(count(last, "last_seq")) : null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
thread(id: string, threadId: string, params: URLSearchParams): ThreadDetail {
|
|
198
|
+
this.board(id);
|
|
199
|
+
const root = this.all(
|
|
200
|
+
"SELECT id,channel,author,created_at,text FROM posts WHERE board_id=? AND id=? AND root=id",
|
|
201
|
+
id,
|
|
202
|
+
threadId,
|
|
203
|
+
)[0];
|
|
204
|
+
if (!root) throw new Error("Thread not found in this board");
|
|
205
|
+
const rows = this.all(
|
|
206
|
+
"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",
|
|
207
|
+
id,
|
|
208
|
+
threadId,
|
|
209
|
+
this.cursor(params) ?? 0,
|
|
210
|
+
);
|
|
211
|
+
const visible = rows.slice(0, 80);
|
|
212
|
+
const last = visible.at(-1);
|
|
213
|
+
return {
|
|
214
|
+
root: post(root),
|
|
215
|
+
replies: visible.map(post),
|
|
216
|
+
nextCursor: rows.length > 80 && last ? String(count(last, "seq")) : null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private cursor(params: URLSearchParams): number | undefined {
|
|
221
|
+
const value = params.get("cursor");
|
|
222
|
+
if (value === null) return undefined;
|
|
223
|
+
if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
224
|
+
throw new Error("Invalid page cursor");
|
|
225
|
+
return Number(value);
|
|
226
|
+
}
|
|
227
|
+
}
|