@jeffober/thatch 0.1.39 → 0.1.41

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 (40) hide show
  1. package/README.md +10 -3
  2. package/artifacts/skills/common.md +4 -0
  3. package/artifacts/skills/thatch-code-review.md +10 -4
  4. package/artifacts/skills/thatch-review-synthesizer.md +57 -6
  5. package/artifacts/skills/thatch-review-technique.md +173 -0
  6. package/bin/thatch +160 -16
  7. package/docs/dev/README.md +26 -14
  8. package/docs/dev/features/README.md +1 -0
  9. package/docs/dev/features/behavior-engine.md +5 -3
  10. package/docs/dev/features/cli.md +22 -4
  11. package/docs/dev/features/cross-session-chat.md +256 -0
  12. package/docs/dev/features/database.md +52 -4
  13. package/docs/dev/features/memory-store.md +13 -0
  14. package/docs/dev/features/multi-host.md +17 -9
  15. package/docs/dev/features/prediction-engine.md +6 -3
  16. package/docs/dev/features/setup.md +12 -5
  17. package/docs/dev/gotchas.md +7 -4
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +5 -4
  20. package/docs/dev/skills.md +7 -3
  21. package/docs/user/README.md +24 -4
  22. package/docs/user/cli.md +30 -0
  23. package/docs/user/code-review.md +4 -3
  24. package/docs/user/cross-session-chat.md +102 -0
  25. package/docs/user/memory.md +3 -0
  26. package/docs/user/setup.md +20 -4
  27. package/docs/user/skills.md +7 -5
  28. package/package.json +2 -1
  29. package/src/behavior.ts +1 -1
  30. package/src/chat-names.ts +121 -0
  31. package/src/chat.ts +879 -0
  32. package/src/db.ts +181 -2
  33. package/src/embeddings.ts +152 -9
  34. package/src/index.ts +113 -5
  35. package/src/prediction.ts +1 -1
  36. package/src/prompts.ts +133 -8
  37. package/src/scoring-engine.ts +25 -4
  38. package/src/setup.ts +83 -29
  39. package/src/skills.ts +47 -16
  40. package/src/tool-defs.ts +422 -40
package/src/chat.ts ADDED
@@ -0,0 +1,879 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { CHAT_NAME_POOL } from "./chat-names";
3
+
4
+ /**
5
+ * Cross-session chat: opt-in messaging between live opencode sessions on one
6
+ * machine, routed through the shared thatch.db.
7
+ *
8
+ * The design splits cleanly into two halves. ChatStore is the shared state:
9
+ * the session directory and the message inbox live in SQLite so any opencode
10
+ * process on the machine can read and write them. ChatPoller is the local
11
+ * delivery: each process polls the inbox for messages addressed to sessions
12
+ * it hosts and wake-prompts them through its own SDK client. A sender never
13
+ * prompts a session in another process - only the recipient's host delivers.
14
+ * This crosses process boundaries while preserving the watcher rationale's core
15
+ * property: no process ever prompts a session it does not host, so there is
16
+ * exactly one possible deliverer per message and no cross-process double
17
+ * delivery. Within a host process, delivery is at-least-once: a crash between
18
+ * the wake prompt and its delivered_at stamp re-nudges the same mail after
19
+ * restart, which is the safe direction for prompts that only point at the
20
+ * inbox.
21
+ *
22
+ * Liveness is heartbeat-based. A crashed process fires no session.deleted
23
+ * event, so the only reliable offline signal is a stale last_seen value: each
24
+ * host process refreshes last_seen for its registered sessions on every poll
25
+ * cycle, and chat_list marks anything older than the staleness threshold as
26
+ * stale instead of hiding it.
27
+ *
28
+ * Loop control has a soft and a hard brake. The prompt guidance tells agents
29
+ * not to auto-reply reflexively (two polite agents acknowledging each other
30
+ * forever is an infinite wake cycle); the poller's per-recipient nudge cap
31
+ * stops it mechanically regardless of what the models decide to do.
32
+ */
33
+
34
+ /** ISO-8601 UTC timestamp with second resolution, matching the house
35
+ * strftime('%Y-%m-%dT%H:%M:%SZ','now') default so string comparison between
36
+ * SQLite-generated and JS-generated timestamps stays consistent. */
37
+ export function nowIso(): string {
38
+ return isoMinutesAgo(0);
39
+ }
40
+
41
+ /** An ISO timestamp `minutes` in the past, in the same second-resolution
42
+ * format. The poller passes this shape as the re-nudge cutoff; tests use
43
+ * it for the same purpose, so the format expression lives in exactly one
44
+ * place. */
45
+ export function isoMinutesAgo(minutes: number): string {
46
+ return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
47
+ }
48
+
49
+ export interface ChatSessionRow {
50
+ session_id: string;
51
+ name: string;
52
+ topic: string | null;
53
+ project: string | null;
54
+ /** Which harness hosts this session: "opencode" (poller-driven) or "mcp"
55
+ * (turn-driven). Determines how staleness is interpreted and whether
56
+ * wake delivery is possible. */
57
+ host_kind: ChatHostKind;
58
+ registered_at: string;
59
+ last_seen: string;
60
+ }
61
+
62
+ /** One message selected for wake-up delivery, with the sender's display name
63
+ * resolved for the nudge text. */
64
+ export interface ChatNotificationRow {
65
+ id: number;
66
+ to_session: string;
67
+ from_session: string;
68
+ from_name: string | null;
69
+ }
70
+
71
+ /** A message as returned to the reading agent. */
72
+ export interface ChatInboxItem {
73
+ id: number;
74
+ from_session: string;
75
+ from_name: string | null;
76
+ body: string;
77
+ created_at: string;
78
+ }
79
+
80
+ export type ChatResult = { ok: true; topic: string | null } | { ok: false; error: string };
81
+
82
+ /**
83
+ * Derives the synthetic session ID for an MCP-host chat registration: a
84
+ * pure function of the claimed name, so identity survives the ephemeral
85
+ * sessions those hosts run (a later conversation claiming the same name
86
+ * finds the same row). The underscore keeps the ID outside NAME_CHARSET,
87
+ * preserving the name-cannot-shadow-ID invariant that find() relies on.
88
+ */
89
+ export function mcpSessionID(name: string): string {
90
+ const hash = Bun.SHA256.hash(name.toLowerCase(), "hex").slice(0, 12);
91
+ return `mcp_${hash}`;
92
+ }
93
+
94
+ /** Which kind of harness hosts a chat session: opencode rows are
95
+ * poller-driven (liveness = process alive), mcp rows are turn-driven
96
+ * (liveness = a turn ran recently; mail is read at the next prompt). */
97
+ export type ChatHostKind = "opencode" | "mcp";
98
+
99
+ // Display names appear inside wake prompts and chat_list output, so they are
100
+ // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
101
+ // into a context bomb; the reader fetches full content via chat_read anyway.
102
+ const MAX_NAME_LEN = 40;
103
+ const MAX_BODY_LEN = 10_000;
104
+
105
+ // Custom names share the pool's charset: letters, numbers, spaces,
106
+ // apostrophes, hyphens, periods. Parens would truncate the transcript
107
+ // echo's name parse; newlines or tabs would break chat_list's one-line
108
+ // roster format. Exported so the pool conformance test can pin that every
109
+ // baked-in name satisfies it.
110
+ export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
111
+
112
+ // Topics are free text (unlike names) but must stay one line for the
113
+ // chat_list roster, so whitespace runs collapse and the value is capped.
114
+ const MAX_TOPIC_LEN = 80;
115
+
116
+ // Timing defaults, shared by the poller and the chat_list staleness display
117
+ // so both agree on what "stale" means.
118
+ export const CHAT_POLL_INTERVAL_MS = 30_000;
119
+ export const CHAT_STALE_MINUTES = 10;
120
+ export const CHAT_RENUDGE_MINUTES = 15;
121
+ export const CHAT_MAX_NUDGES_PER_HOUR = 6;
122
+
123
+ /**
124
+ * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
125
+ * Database handle; the tables are created by ThatchDB's schema init.
126
+ */
127
+ export class ChatStore {
128
+ #db: Database;
129
+
130
+ constructor(db: Database) {
131
+ this.#db = db;
132
+ }
133
+
134
+ /** Joins the directory with a pool name: picks uniformly at random from the
135
+ * names no other session has claimed (case-insensitively). Idempotent for
136
+ * an already-registered session - it keeps its current name (drawn:
137
+ * false). Fails when every pool name is taken, or when concurrent
138
+ * registrations win every draw attempt - see the redraw loop below.
139
+ */
140
+ assign(
141
+ sessionID: string,
142
+ project: string | null,
143
+ topic: string | null,
144
+ kind: ChatHostKind,
145
+ ): { ok: true; name: string; drawn: boolean; topic: string | null } | { ok: false; error: string } {
146
+ const existing = this.#find(sessionID);
147
+ if (existing) {
148
+ this.#touch(sessionID);
149
+ const cleaned = this.#cleanTopic(topic);
150
+ this.#maybeUpdateTopic(sessionID, existing.topic, cleaned);
151
+ return { ok: true, name: existing.name, drawn: false, topic: cleaned ?? existing.topic };
152
+ }
153
+ // Two processes can snapshot the same free list and draw the same name;
154
+ // the loser's INSERT hits the constraint. Redrawing from the names
155
+ // still free (the taken set is re-read each attempt) keeps the pool
156
+ // path's cannot-collide promise even under concurrent registration.
157
+ for (let attempt = 0; attempt < 5; attempt++) {
158
+ const taken = new Set(this.list().map((r) => r.name.toLowerCase()));
159
+ const free = CHAT_NAME_POOL.filter((n) => !taken.has(n.toLowerCase()));
160
+ if (free.length === 0) {
161
+ return { ok: false, error: "Name pool exhausted - pass a custom name." };
162
+ }
163
+ const name = free[Math.floor(Math.random() * free.length)];
164
+ const cleanTopic = this.#cleanTopic(topic);
165
+ const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
166
+ if (claimed.ok) return { ok: true, name, drawn: true, topic: cleanTopic };
167
+ }
168
+ return { ok: false, error: "Could not claim a pool name after several attempts - pass a custom name." };
169
+ }
170
+
171
+ /**
172
+ * Joins the directory, or refreshes an existing registration. Re-registering
173
+ * with the same session ID renames the session (including changing only
174
+ * the casing of its own name); the new name must not be claimed by a
175
+ * different session. Uniqueness is case-insensitive ("Landru" and "landru"
176
+ * are the same name), so lookups and claims agree no matter what casing the
177
+ * model or user types. Both paths stamp last_seen.
178
+ */
179
+ register(sessionID: string, rawName: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
180
+ const name = rawName.trim();
181
+ const invalid = this.#invalidName(name);
182
+ if (invalid) return { ok: false, error: invalid };
183
+ const cleanTopic = this.#cleanTopic(topic);
184
+ const existing = this.#find(sessionID);
185
+ if (existing) {
186
+ if (existing.name === name) {
187
+ this.#touch(sessionID);
188
+ this.#maybeUpdateTopic(sessionID, existing.topic, cleanTopic);
189
+ // The kept topic: omitted keeps existing, "" clears to null (the
190
+ // read-side normalization this return path mirrors).
191
+ return { ok: true, topic: cleanTopic ?? existing.topic };
192
+ }
193
+ // A NOCASE hit is only a collision when it belongs to a different
194
+ // session - otherwise this is the caller recasing its own name.
195
+ const clash = this.#findByName(name);
196
+ if (clash && clash.session_id !== sessionID) {
197
+ return this.#nameTaken(name);
198
+ }
199
+ try {
200
+ // An omitted topic (null) keeps the existing one, same as the
201
+ // same-name path below - a rename is a re-register, and the tool
202
+ // treats name and topic updates as independent.
203
+ const nextTopic = cleanTopic ?? existing.topic;
204
+ this.#db.run("UPDATE chat_sessions SET name = ?, topic = ?, last_seen = ? WHERE session_id = ?", [
205
+ name,
206
+ nextTopic,
207
+ nowIso(),
208
+ sessionID,
209
+ ]);
210
+ return { ok: true, topic: nextTopic };
211
+ } catch (err) {
212
+ if (this.#isConstraintError(err)) {
213
+ return this.#nameTaken(name);
214
+ }
215
+ throw err;
216
+ }
217
+ }
218
+ if (this.#findByName(name)) {
219
+ return this.#nameTaken(name);
220
+ }
221
+ const inserted = this.#insertSession(sessionID, name, project, cleanTopic, kind);
222
+ if (inserted.ok) return { ok: true, topic: cleanTopic };
223
+ return inserted;
224
+ }
225
+
226
+ /** Leaves the directory. Messages already sent to or from the session are
227
+ * kept as history; departed senders show as unknown names to readers. */
228
+ unregister(sessionID: string): boolean {
229
+ const existing = this.#find(sessionID);
230
+ if (!existing) return false;
231
+ this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
232
+ return true;
233
+ }
234
+
235
+ list(): ChatSessionRow[] {
236
+ return (this.#db
237
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
238
+ .all() as any[]).map(rowFromSession);
239
+ }
240
+
241
+ /** Resolves a recipient by display name (case-insensitive) or session ID.
242
+ * Name-first is safe only because opencode session IDs contain
243
+ * underscores and NAME_CHARSET excludes them, so no legal display name
244
+ * can shadow an ID; if either format ever changes, resolve IDs first. */
245
+ find(nameOrID: string): ChatSessionRow | null {
246
+ return this.#findByName(nameOrID) ?? this.#find(nameOrID);
247
+ }
248
+
249
+ /**
250
+ * Posts a message. Both endpoints must be registered; a session cannot
251
+ * message itself (nothing is coordinated: the message would sit unread
252
+ * until the sender drains its own inbox, and the wake prompt would be the
253
+ * sender nudging itself). Returns the resolved recipient so callers never
254
+ * need a second lookup - a re-lookup could race a concurrent unregister
255
+ * and fail after the message already landed.
256
+ */
257
+ send(
258
+ fromSession: string,
259
+ toNameOrID: string,
260
+ body: string,
261
+ ): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
262
+ const trimmed = body.trim();
263
+ if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
264
+ if (trimmed.length > MAX_BODY_LEN) {
265
+ return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
266
+ }
267
+ const sender = this.#find(fromSession);
268
+ if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
269
+ const recipient = this.find(toNameOrID);
270
+ if (!recipient) {
271
+ return { ok: false, error: `No registered session named "${toNameOrID}". Use chat_list to see who is available.` };
272
+ }
273
+ if (recipient.session_id === fromSession) {
274
+ return { ok: false, error: "You cannot message yourself." };
275
+ }
276
+ this.#db.run(
277
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
278
+ [fromSession, recipient.session_id, trimmed, nowIso()],
279
+ );
280
+ return { ok: true, recipient: { session_id: recipient.session_id, name: recipient.name } };
281
+ }
282
+
283
+ /**
284
+ * Posts a message to every other registered session at once. Stale
285
+ * sessions are skipped, not messaged - a host process that has stopped
286
+ * heartbeat-ing will never read the mail, and a broadcast is for reaching
287
+ * live agents. Each recipient gets its own inbox row, so the existing
288
+ * wake machinery (grouping, gating, rate cap) treats the broadcast as
289
+ * ordinary per-recipient mail.
290
+ */
291
+ broadcast(
292
+ fromSession: string,
293
+ body: string,
294
+ ): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
295
+ const trimmed = body.trim();
296
+ if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
297
+ if (trimmed.length > MAX_BODY_LEN) {
298
+ return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
299
+ }
300
+ const sender = this.#find(fromSession);
301
+ if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
302
+ const recipients: string[] = [];
303
+ const skipped: string[] = [];
304
+ // One transaction: a crash mid-loop rolls the whole fan-out back
305
+ // instead of leaving a partial broadcast where some recipients got the
306
+ // message and others silently did not.
307
+ this.#db.transaction(() => {
308
+ for (const row of this.list()) {
309
+ if (row.session_id === fromSession) continue;
310
+ // Staleness means "host process gone" only for opencode rows; an
311
+ // mcp row between turns reads its mail at the next prompt, so it
312
+ // always receives.
313
+ if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
314
+ skipped.push(row.name);
315
+ continue;
316
+ }
317
+ this.#db.run(
318
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
319
+ [fromSession, row.session_id, trimmed, nowIso()],
320
+ );
321
+ recipients.push(row.name);
322
+ }
323
+ })();
324
+ return { ok: true, recipients, skipped };
325
+ }
326
+
327
+ /**
328
+ * Drains the calling session's inbox: returns unread messages oldest-first
329
+ * and stamps exactly those rows read. The stamp is scoped to the selected
330
+ * ids, not re-derived - a message arriving from another process between
331
+ * the SELECT and the UPDATE must stay unread so the poller re-nudges it;
332
+ * an unscoped stamp would swallow it silently.
333
+ */
334
+ read(sessionID: string): ChatInboxItem[] {
335
+ const rows = this.#db
336
+ .query(
337
+ `SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
338
+ FROM chat_messages m
339
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
340
+ WHERE m.to_session = ? AND m.read_at IS NULL
341
+ ORDER BY m.id`,
342
+ )
343
+ .all(sessionID) as any[];
344
+ if (rows.length === 0) return [];
345
+ const marks = rows.map(() => "?").join(",");
346
+ this.#db.run(`UPDATE chat_messages SET read_at = ? WHERE id IN (${marks})`, [
347
+ nowIso(),
348
+ ...rows.map((r) => r.id),
349
+ ]);
350
+ return rows.map((r) => ({
351
+ id: r.id,
352
+ from_session: r.from_session,
353
+ from_name: r.from_name ?? null,
354
+ body: r.body,
355
+ created_at: r.created_at,
356
+ }));
357
+ }
358
+
359
+ /** Unread message count for a session, for chat_list's self row. */
360
+ unreadCount(sessionID: string): number {
361
+ const row = this.#db
362
+ .query("SELECT COUNT(*) AS n FROM chat_messages WHERE to_session = ? AND read_at IS NULL")
363
+ .get(sessionID) as any;
364
+ return row.n;
365
+ }
366
+
367
+ /**
368
+ * The caller's mailbox summary for chat_status: whether a directory row
369
+ * exists, and pending/total counts. Touches last_seen on use, which is
370
+ * what keeps an active MCP session's roster row fresh between prompt-time
371
+ * checks. An unregistered caller gets registered: false - the explicit,
372
+ * quiet answer that replaces any nudge.
373
+ */
374
+ status(sessionID: string): { registered: true; name: string; pending: number; total: number } | { registered: false } {
375
+ const row = this.#find(sessionID);
376
+ if (!row) return { registered: false };
377
+ this.#touch(sessionID);
378
+ const counts = this.#db
379
+ .query(
380
+ `SELECT COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS pending
381
+ FROM chat_messages WHERE to_session = ?`,
382
+ )
383
+ .get(sessionID) as any;
384
+ return {
385
+ registered: true,
386
+ name: row.name,
387
+ pending: counts.pending ?? 0,
388
+ total: counts.total ?? 0,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Pending-mail summary for the hook channel (flush-tools / reminder):
394
+ * every registered session in the project with unread mail, with its
395
+ * display name and unread count. The hook prints this only when
396
+ * non-empty, and names the addressee - a session that never registered
397
+ * never sees a chat line at all.
398
+ */
399
+ pendingByProject(project: string): Array<{ name: string; pending: number }> {
400
+ return (
401
+ this.#db
402
+ .query(
403
+ `SELECT cs.name AS name, COUNT(*) AS pending
404
+ FROM chat_messages m
405
+ JOIN chat_sessions cs ON cs.session_id = m.to_session
406
+ WHERE m.read_at IS NULL AND cs.project = ?
407
+ GROUP BY cs.name
408
+ ORDER BY cs.name`,
409
+ )
410
+ .all(project) as any[]
411
+ ).map((r) => ({ name: r.name, pending: r.pending }));
412
+ }
413
+
414
+ /** Refreshes last_seen for the given sessions. Sessions that never
415
+ * registered are absent from chat_sessions, so the UPDATE is a no-op for
416
+ * them - no filtering needed on the caller's side. */
417
+ heartbeat(sessionIDs: string[]): void {
418
+ if (sessionIDs.length === 0) return;
419
+ const marks = sessionIDs.map(() => "?").join(",");
420
+ this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
421
+ nowIso(),
422
+ ...sessionIDs,
423
+ ]);
424
+ }
425
+
426
+ /**
427
+ * The full message feed for `thatch chat tail`: every row with sender and
428
+ * recipient display names resolved (departed senders degrade to unknown),
429
+ * oldest first. The CLI diffs consecutive snapshots to emit sent and
430
+ * read events; the table is machine-scale, so a full scan per poll is
431
+ * cheap.
432
+ */
433
+ messageFeed(): Array<{
434
+ id: number;
435
+ from: string | null;
436
+ to: string | null;
437
+ viaBroadcast: boolean;
438
+ body: string;
439
+ created_at: string;
440
+ read_at: string | null;
441
+ }> {
442
+ return (
443
+ this.#db
444
+ .query(
445
+ `SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, m.via_broadcast, m.body, m.created_at, m.read_at
446
+ FROM chat_messages m
447
+ LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
448
+ LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
449
+ ORDER BY m.id`,
450
+ )
451
+ .all() as any[]
452
+ ).map((r) => ({
453
+ id: r.id,
454
+ from: renderChatParticipant(r.from_name, r.from_session),
455
+ to: renderChatParticipant(r.to_name, r.to_session),
456
+ viaBroadcast: r.via_broadcast === 1,
457
+ body: r.body,
458
+ created_at: r.created_at,
459
+ read_at: r.read_at ?? null,
460
+ }));
461
+ }
462
+
463
+ /**
464
+ * Messages that need a wake prompt for the given sessions: unread and
465
+ * either never delivered, or delivered so long ago that a re-nudge is due.
466
+ * Only registered recipients are selected - unregistering must stop wake
467
+ * prompts for kept-but-unread mail, which is exactly what the
468
+ * chat_unregister tool promises. The cutoff is passed by the poller (its
469
+ * renudge window, as an ISO timestamp); delivered_at comparison is string
470
+ * comparison, which works because every timestamp in these tables uses
471
+ * the same second-resolution format.
472
+ */
473
+ pendingNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[] {
474
+ if (sessionIDs.length === 0) return [];
475
+ const marks = sessionIDs.map(() => "?").join(",");
476
+ return (
477
+ this.#db
478
+ .query(
479
+ `SELECT m.id, m.to_session, m.from_session, s.name AS from_name
480
+ FROM chat_messages m
481
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
482
+ WHERE m.read_at IS NULL
483
+ AND m.to_session IN (${marks})
484
+ AND (m.delivered_at IS NULL OR m.delivered_at <= ?)
485
+ AND EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.session_id = m.to_session)
486
+ ORDER BY m.id`,
487
+ )
488
+ .all(...sessionIDs, renudgeCutoff) as any[]
489
+ ).map((r) => ({
490
+ id: r.id,
491
+ to_session: r.to_session,
492
+ from_session: r.from_session,
493
+ from_name: r.from_name ?? null,
494
+ }));
495
+ }
496
+
497
+ /** Stamps delivered_at on the given messages. Called after a wake prompt
498
+ * was accepted; re-stamping on later nudges resets the re-nudge timer. */
499
+ markDelivered(ids: number[]): void {
500
+ if (ids.length === 0) return;
501
+ const marks = ids.map(() => "?").join(",");
502
+ this.#db.run(`UPDATE chat_messages SET delivered_at = ? WHERE id IN (${marks})`, [nowIso(), ...ids]);
503
+ }
504
+
505
+ #find(sessionID: string): ChatSessionRow | null {
506
+ const row = this.#db
507
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
508
+ .get(sessionID) as any;
509
+ return row ? rowFromSession(row) : null;
510
+ }
511
+
512
+ #findByName(name: string): ChatSessionRow | null {
513
+ const row = this.#db
514
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
515
+ .get(name) as any;
516
+ return row ? rowFromSession(row) : null;
517
+ }
518
+
519
+ #touch(sessionID: string): void {
520
+ this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
521
+ }
522
+
523
+ #invalidName(name: string): string | null {
524
+ if (!name) return "Name cannot be empty.";
525
+ if (name.length > MAX_NAME_LEN) return `Name too long (max ${MAX_NAME_LEN} characters).`;
526
+ if (!NAME_CHARSET.test(name)) {
527
+ return "Names may contain letters, numbers, spaces, apostrophes, hyphens, and periods.";
528
+ }
529
+ return null;
530
+ }
531
+
532
+ /** Collapses whitespace runs (topics must stay one roster line) and caps
533
+ * the length. The null/empty distinction is load-bearing: null means
534
+ * "omit on re-register, keep the existing topic", while empty or
535
+ * whitespace-only means "clear it" - so this never turns one into the
536
+ * other. Oversized topics are capped, never an error: a topic is
537
+ * advisory. */
538
+ #cleanTopic(topic: string | null): string | null {
539
+ if (topic === null) return null;
540
+ return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
541
+ }
542
+
543
+ /** Refreshes the topic on re-registration when it changed. Omitting the
544
+ * topic (null) keeps the existing one; passing an empty topic clears it. */
545
+ #maybeUpdateTopic(sessionID: string, current: string | null, next: string | null): void {
546
+ if (next === null || next === current) return;
547
+ this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ?", [next, sessionID]);
548
+ }
549
+
550
+ /**
551
+ * INSERTs a directory row, translating a UNIQUE-constraint violation into
552
+ * the friendly taken message. The pre-checks are advisory only against
553
+ * other processes: two sessions claiming one free name in the same
554
+ * instant both pass the check, and the loser's INSERT must not escape the
555
+ * { ok, error } contract as a raw SQLite error. Same pattern as
556
+ * remember()'s slug-collision handling in db.ts.
557
+ */
558
+ #insertSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
559
+ try {
560
+ this.#db.run(
561
+ "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
562
+ [sessionID, name, topic, project, kind, nowIso(), nowIso()],
563
+ );
564
+ return { ok: true, topic };
565
+ } catch (err) {
566
+ // The two constraints fail differently: a UNIQUE hit on name means
567
+ // someone else claimed it; a PK hit on session_id means this session
568
+ // is somehow registered twice, which is an identity event, not a
569
+ // naming one.
570
+ if (this.#isConstraintError(err)) {
571
+ return this.#alreadyRegistered();
572
+ }
573
+ throw err;
574
+ }
575
+ }
576
+
577
+ #isConstraintError(err: unknown): boolean {
578
+ return String((err as any)?.code ?? err).includes("CONSTRAINT");
579
+ }
580
+
581
+ /** The one taken-message every claim path returns, so a rewording cannot
582
+ * drift between the pre-checks and the race catches. Points at the
583
+ * guaranteed-success recovery (a pool draw) like every other failure
584
+ * path in this feature. */
585
+ #nameTaken(name: string): ChatResult {
586
+ return {
587
+ ok: false,
588
+ error: `Name "${name}" is taken by another session - pick another name, or omit the name to draw one from the pool.`,
589
+ };
590
+ }
591
+
592
+ /** The one already-registered message for a session-ID PRIMARY KEY hit -
593
+ * a constraint collision that is not a taken name (the caller's own
594
+ * session row already exists under a different identity path). */
595
+ #alreadyRegistered(): ChatResult {
596
+ return { ok: false, error: "You are already registered - call chat_list to see your current name." };
597
+ }
598
+ }
599
+
600
+ function rowFromSession(r: any): ChatSessionRow {
601
+ return {
602
+ session_id: r.session_id,
603
+ name: r.name,
604
+ // An empty-string topic (explicit clear) reads back as no topic, so the
605
+ // API never leaks the sentinel.
606
+ topic: r.topic || null,
607
+ host_kind: r.host_kind === "mcp" ? "mcp" : "opencode",
608
+ project: r.project,
609
+ registered_at: r.registered_at,
610
+ last_seen: r.last_seen,
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Renders a chat participant for user-visible surfaces (wake prompts, the
616
+ * tail feed, chat_read): the display name when the directory row still
617
+ * exists, otherwise the shared unknown-departed convention. One helper
618
+ * because the exact string is a cross-surface convention - a reword here
619
+ * must reach every surface or the reader sees two names for the same
620
+ * condition.
621
+ */
622
+ export function renderChatParticipant(name: string | null, sessionID: string | null | undefined): string {
623
+ if (name) return name;
624
+ return `unknown (${String(sessionID ?? "").slice(0, 12)}, departed)`;
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // Tail events (pure logic behind `thatch chat tail`)
629
+ // ---------------------------------------------------------------------------
630
+
631
+ export type ChatTailEvent =
632
+ | { kind: "sent"; timestamp: string; from: string; to: string; body: string }
633
+ | { kind: "read"; timestamp: string; reader: string; from: string; body: string };
634
+
635
+ /** One message row as the tail diff consumes it. */
636
+ export interface ChatTailRow {
637
+ id: number;
638
+ from: string | null;
639
+ to: string | null;
640
+ viaBroadcast: boolean;
641
+ body: string;
642
+ created_at: string;
643
+ read_at: string | null;
644
+ }
645
+
646
+ /**
647
+ * Diffs one feed snapshot against the previous one, emitting sent events
648
+ * for rows the poller has not seen and read events for rows whose read_at
649
+ * appeared since the last poll. Pure: the caller owns the state map (start
650
+ * empty for a fresh tail) and updates it from the returned state. A message
651
+ * inserted and read between two polls emits only its sent line - the sent
652
+ * event is the primary record, and the read of a message nobody saw as sent
653
+ * adds nothing.
654
+ */
655
+ export function chatTailDiff(
656
+ prev: Map<number, string | null>,
657
+ rows: Array<ChatTailRow>,
658
+ ): { events: ChatTailEvent[]; state: Map<number, string | null> } {
659
+ const events: ChatTailEvent[] = [];
660
+ for (const r of rows) {
661
+ const isNew = !prev.has(r.id);
662
+ if (isNew) {
663
+ events.push({
664
+ kind: "sent",
665
+ timestamp: r.created_at,
666
+ from: r.from ?? "unknown",
667
+ to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
668
+ body: r.body,
669
+ });
670
+ } else {
671
+ const prevRead = prev.get(r.id) ?? null;
672
+ if (r.read_at !== null && prevRead === null) {
673
+ events.push({
674
+ kind: "read",
675
+ timestamp: r.read_at,
676
+ reader: r.to ?? "unknown",
677
+ from: r.from ?? "unknown",
678
+ body: r.body,
679
+ });
680
+ }
681
+ }
682
+ prev.set(r.id, r.read_at);
683
+ }
684
+ return { events, state: prev };
685
+ }
686
+
687
+ /** Renders one tail event in the CLI's line format. */
688
+ export function formatChatTailEvent(event: ChatTailEvent): string {
689
+ if (event.kind === "sent") {
690
+ return `[${event.timestamp}] ${event.from} -> ${event.to}: ${event.body}`;
691
+ }
692
+ const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
693
+ return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Poller
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /** The poller's narrow view of the store: the three chat methods it needs.
701
+ * ThatchDB satisfies this structurally through its delegated chat methods.
702
+ * The interface pins the poller to exactly these dependencies and removes
703
+ * chat.ts's need to import db.ts at all - db.ts already value-imports
704
+ * ChatStore, so a reverse import would form a runtime module cycle. A
705
+ * test that wants a fake store can pass a plain object, though the suite
706
+ * currently uses real temp-dir databases. */
707
+ export interface ChatPollerStore {
708
+ heartbeatChatSessions(sessionIDs: string[]): void;
709
+ pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
710
+ markChatDelivered(ids: number[]): void;
711
+ }
712
+
713
+ export interface ChatPollerOptions {
714
+ store: ChatPollerStore;
715
+ /** Session IDs this process has seen events for. Only sessions hosted by
716
+ * this process can be delivered to - the recipient's host is the single
717
+ * deliverer, by design. */
718
+ hostedSessions: () => string[];
719
+ /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
720
+ deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
721
+ /** Gate for delivery: true only when the session can accept a proactive
722
+ * prompt right now (idle, not compacting). Undeliverable messages stay
723
+ * pending and retry on later cycles. */
724
+ canDeliver: (sessionID: string) => boolean;
725
+ /** Poll interval. Default 30s - frequent enough that wake prompts feel
726
+ * prompt, cheap enough that the shared DB sees only a couple of light
727
+ * statements per cycle per process. */
728
+ pollIntervalMs?: number;
729
+ /** A delivered-but-unread message re-queues a nudge after this long.
730
+ * Default 15 minutes. */
731
+ renudgeMinutes?: number;
732
+ /** Hard brake on wake cycles: maximum wake prompts per recipient per hour.
733
+ * Default 6. In-memory on purpose - it is a loop guard, not accounting. */
734
+ maxNudgesPerHour?: number;
735
+ }
736
+
737
+ /**
738
+ * Background poller: heartbeats hosted sessions, delivers wake prompts for
739
+ * pending chat messages. Mirrors the watcher registry's shape (start/stop/
740
+ * dispose, re-entrant guards, pending-retry semantics) but its pending state
741
+ * lives in SQLite rather than memory, because the sender may be a different
742
+ * process than the deliverer.
743
+ */
744
+ export class ChatPoller {
745
+ #opts: Required<ChatPollerOptions>;
746
+ #timer: ReturnType<typeof setInterval> | null = null;
747
+ #polling = false;
748
+ #delivering = false;
749
+ /** Per-recipient wake-prompt timestamps inside the last hour. */
750
+ #nudges = new Map<string, number[]>();
751
+
752
+ constructor(opts: ChatPollerOptions) {
753
+ this.#opts = {
754
+ pollIntervalMs: CHAT_POLL_INTERVAL_MS,
755
+ renudgeMinutes: CHAT_RENUDGE_MINUTES,
756
+ maxNudgesPerHour: CHAT_MAX_NUDGES_PER_HOUR,
757
+ ...opts,
758
+ };
759
+ }
760
+
761
+ /** True while the poller timer is armed. */
762
+ get running(): boolean {
763
+ return this.#timer !== null;
764
+ }
765
+
766
+ start(): void {
767
+ if (this.#timer !== null) return;
768
+ this.#timer = setInterval(() => void this.poll(), this.#opts.pollIntervalMs);
769
+ // Never keep the host process alive just for the poller - matches the
770
+ // watcher registry's timer.
771
+ this.#timer.unref?.();
772
+ }
773
+
774
+ stop(): void {
775
+ if (this.#timer === null) return;
776
+ clearInterval(this.#timer);
777
+ this.#timer = null;
778
+ }
779
+
780
+ /** Stops polling and drops the nudge counters. The inbox itself is shared
781
+ * SQLite state and outlives this process by design. */
782
+ dispose(): void {
783
+ this.stop();
784
+ this.#nudges.clear();
785
+ }
786
+
787
+ /**
788
+ * One poll cycle: heartbeat hosted sessions, then deliver whatever is
789
+ * pending for sessions that can accept a prompt. Re-entrant calls are
790
+ * dropped, matching the watcher registry - a slow cycle overlapping the
791
+ * next would double-deliver the same messages. Store failures are caught
792
+ * here, not just deliver failures: the timer discards the returned
793
+ * promise, so an escaping SQLite error would surface as an unhandled
794
+ * rejection in the plugin host process. A failed cycle logs and retries
795
+ * on the next tick.
796
+ */
797
+ async poll(): Promise<void> {
798
+ if (this.#polling) return;
799
+ this.#polling = true;
800
+ try {
801
+ const hosted = this.#opts.hostedSessions();
802
+ this.#opts.store.heartbeatChatSessions(hosted);
803
+ await this.deliverPending();
804
+ } catch (err) {
805
+ console.error(`[thatch] chat poll failed: ${err}`);
806
+ } finally {
807
+ this.#polling = false;
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Delivers pending messages for every hosted session that can accept a
813
+ * prompt. Sessions that are busy keep their messages pending; the idle
814
+ * event handler calls this directly so mail lands promptly instead of
815
+ * waiting for the next cycle. Failures stay pending and retry. Delivery
816
+ * is at-least-once: a crash after the wake prompt but before
817
+ * markChatDelivered re-nudges the same batch on the next cycle.
818
+ */
819
+ async deliverPending(): Promise<void> {
820
+ if (this.#delivering) return;
821
+ this.#delivering = true;
822
+ try {
823
+ const hosted = this.#opts.hostedSessions();
824
+ const cutoff = isoMinutesAgo(this.#opts.renudgeMinutes);
825
+ const pending = this.#opts.store.pendingChatNotifications(hosted, cutoff);
826
+ if (pending.length === 0) return;
827
+
828
+ const byRecipient = new Map<string, ChatNotificationRow[]>();
829
+ for (const msg of pending) {
830
+ const queue = byRecipient.get(msg.to_session) ?? [];
831
+ queue.push(msg);
832
+ byRecipient.set(msg.to_session, queue);
833
+ }
834
+
835
+ for (const [recipient, messages] of byRecipient) {
836
+ if (!this.#opts.canDeliver(recipient)) continue;
837
+ if (this.#nudgeBudget(recipient) <= 0) continue;
838
+ try {
839
+ // "unknown (id, departed)" matches chat_read's rendering of a
840
+ // sender whose directory row is gone, so both surfaces use one
841
+ // convention for the same condition.
842
+ const senders = [...new Set(messages.map((m) => renderChatParticipant(m.from_name, m.from_session)))];
843
+ await this.#opts.deliver(recipient, senders, messages.length);
844
+ // Count the nudge before the stamp: the cap must bind on prompts
845
+ // actually sent, not on the DB write succeeding. A failing stamp
846
+ // leaves the mail pending, and an uncounted re-delivery every
847
+ // cycle would be the ping-pong the cap exists to stop.
848
+ this.#recordNudge(recipient);
849
+ this.#opts.store.markChatDelivered(messages.map((m) => m.id));
850
+ } catch (err) {
851
+ console.error(`[thatch] chat delivery to ${recipient} failed: ${err}`);
852
+ }
853
+ }
854
+ } finally {
855
+ this.#delivering = false;
856
+ }
857
+ }
858
+
859
+ /** Remaining wake prompts for this recipient in the current hour. */
860
+ #nudgeBudget(recipient: string): number {
861
+ const now = Date.now();
862
+ const window = this.#nudges.get(recipient) ?? [];
863
+ const fresh = window.filter((t) => now - t < 3_600_000);
864
+ this.#nudges.set(recipient, fresh);
865
+ return this.#opts.maxNudgesPerHour - fresh.length;
866
+ }
867
+
868
+ #recordNudge(recipient: string): void {
869
+ const window = this.#nudges.get(recipient) ?? [];
870
+ window.push(Date.now());
871
+ this.#nudges.set(recipient, window);
872
+ }
873
+ }
874
+
875
+ /** True when a session row's last_seen is older than the staleness
876
+ * threshold. Used by chat_list to mark ghosts. */
877
+ export function isStale(row: ChatSessionRow, staleMinutes: number, now = Date.now()): boolean {
878
+ return now - Date.parse(row.last_seen) > staleMinutes * 60_000;
879
+ }