@jeffober/thatch 0.1.40 → 0.1.42
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/README.md +27 -10
- package/artifacts/skills/thatch-code-review.md +4 -4
- package/artifacts/skills/thatch-review-synthesizer.md +8 -8
- package/artifacts/skills/{thatch-review-mentorship.md → thatch-review-technique.md} +9 -10
- package/bin/release +4 -1
- package/bin/thatch +318 -18
- package/docs/dev/README.md +29 -15
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +33 -4
- package/docs/dev/features/cross-session-chat.md +315 -0
- package/docs/dev/features/database.md +52 -4
- package/docs/dev/features/multi-host.md +18 -9
- package/docs/dev/features/setup.md +12 -5
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +7 -4
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +5 -4
- package/docs/dev/skills.md +5 -4
- package/docs/user/README.md +25 -6
- package/docs/user/cli.md +78 -0
- package/docs/user/code-review.md +1 -1
- package/docs/user/cross-session-chat.md +150 -0
- package/docs/user/setup.md +16 -2
- package/docs/user/skills.md +7 -6
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +120 -0
- package/src/chat.ts +1210 -0
- package/src/config.ts +34 -1
- package/src/db.ts +227 -0
- package/src/index.ts +188 -17
- package/src/prompts.ts +218 -29
- package/src/setup.ts +83 -29
- package/src/skills.ts +47 -17
- package/src/tool-defs.ts +574 -31
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/src/chat.ts
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
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
|
+
/**
|
|
81
|
+
* Derives the synthetic session ID for an MCP-host chat registration: a
|
|
82
|
+
* pure function of the host's own stable session identifier (Claude Code
|
|
83
|
+
* and Cursor pass one in their hook payloads), so identity survives the
|
|
84
|
+
* ephemeral conversations those hosts run. The underscore keeps the ID
|
|
85
|
+
* outside NAME_CHARSET, preserving the name-cannot-shadow-ID invariant
|
|
86
|
+
* that find() relies on.
|
|
87
|
+
*/
|
|
88
|
+
export function mcpSessionID(hostSessionID: string): string {
|
|
89
|
+
const hash = Bun.SHA256.hash(hostSessionID.toLowerCase(), "hex").slice(0, 12);
|
|
90
|
+
return `mcp_${hash}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Which kind of harness hosts a chat session: opencode rows are
|
|
94
|
+
* poller-driven (liveness = process alive), mcp rows are turn-driven
|
|
95
|
+
* (liveness = a turn ran recently; mail is read at the next prompt). */
|
|
96
|
+
export type ChatHostKind = "opencode" | "mcp";
|
|
97
|
+
|
|
98
|
+
// Display names appear inside wake prompts and chat_list output, so they are
|
|
99
|
+
// capped tight. Bodies are capped so a runaway sender cannot turn a nudge
|
|
100
|
+
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
101
|
+
const MAX_BODY_LEN = 10_000;
|
|
102
|
+
|
|
103
|
+
// Assigned names are lowercase slugs with a numeric counter suffix
|
|
104
|
+
// ("fix-auth-bug-00001"), which satisfies NAME_CHARSET. The charset stays
|
|
105
|
+
// exported for the pool conformance test. Slugs never contain underscores,
|
|
106
|
+
// so the name-cannot-shadow-an-ID invariant in find() still holds.
|
|
107
|
+
export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
|
|
108
|
+
|
|
109
|
+
// Topics are free text (unlike names) but must stay one line for the
|
|
110
|
+
// chat_list roster, so whitespace runs collapse and the value is capped.
|
|
111
|
+
const MAX_TOPIC_LEN = 80;
|
|
112
|
+
|
|
113
|
+
// Auto-registered sessions whose host has not heartbeat-ed them for this
|
|
114
|
+
// many days are pruned from the directory. Counters never decrement, so a
|
|
115
|
+
// pruned session's name is never reissued - pruning cannot create identity
|
|
116
|
+
// confusion, only roster silence.
|
|
117
|
+
export const CHAT_AUTO_TTL_DAYS = 7;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Slugifies a session title into a name base: lowercase, letter/number runs
|
|
121
|
+
* joined by single hyphens, capped at 32 characters (leaving room for the
|
|
122
|
+
* 6-character counter suffix). Returns null for titles with no usable
|
|
123
|
+
* characters.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately NOT the same as ThatchDB.slugify (src/db.ts): chat names
|
|
126
|
+
* must never contain underscores (find() resolves name-first, and opencode
|
|
127
|
+
* session IDs contain underscores - a legal display name could shadow an
|
|
128
|
+
* ID), so non-alphanumeric runs all become hyphens and empty input returns
|
|
129
|
+
* null rather than a hash fallback.
|
|
130
|
+
*/
|
|
131
|
+
export function slugifyTitle(title: string): string | null {
|
|
132
|
+
const slug = title
|
|
133
|
+
.toLowerCase()
|
|
134
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
135
|
+
.replace(/^-+|-+$/g, "")
|
|
136
|
+
.slice(0, 32)
|
|
137
|
+
.replace(/-+$/g, "");
|
|
138
|
+
return slug || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether a session title is opencode's pre-autotitle placeholder
|
|
143
|
+
* ("New session - <timestamp>" / "Child session - <timestamp>"). These must
|
|
144
|
+
* not become names or topics: they carry no information about the work, and
|
|
145
|
+
* the auto-titler usually replaces them within the first turn or two.
|
|
146
|
+
*/
|
|
147
|
+
export function isDefaultSessionTitle(title: string): boolean {
|
|
148
|
+
return /^New session - /.test(title) || /^Child session - /.test(title);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Timing defaults, shared by the poller and the chat_list staleness display
|
|
152
|
+
// so both agree on what "stale" means.
|
|
153
|
+
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
154
|
+
export const CHAT_STALE_MINUTES = 10;
|
|
155
|
+
export const CHAT_RENUDGE_MINUTES = 15;
|
|
156
|
+
export const CHAT_MAX_NUDGES_PER_HOUR = 6;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
160
|
+
* Database handle; the tables are created by ThatchDB's schema init.
|
|
161
|
+
*/
|
|
162
|
+
export class ChatStore {
|
|
163
|
+
#db: Database;
|
|
164
|
+
|
|
165
|
+
constructor(db: Database) {
|
|
166
|
+
this.#db = db;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Joins the directory, or refreshes an existing registration. Names are
|
|
171
|
+
* assigned by the system, never claimed: a new session gets
|
|
172
|
+
* "<slug>-<counter>" (e.g. "fix-auth-bug-00001"), where the slug comes
|
|
173
|
+
* from the session title (or a pool-draw slug when there is none) and the
|
|
174
|
+
* per-base counter only ever increments - so a name is minted exactly
|
|
175
|
+
* once, machine-wide, and pruning an old session can never reissue its
|
|
176
|
+
* name to someone else. Idempotent for an already-registered session: it
|
|
177
|
+
* keeps its name and just refreshes liveness.
|
|
178
|
+
*
|
|
179
|
+
* `nameBase` is the slugified title for a new registration (null draws a
|
|
180
|
+
* pool slug instead). `topic` is the live title for auto-registrations;
|
|
181
|
+
* null leaves the topic unset. Every row minted here is marked auto=1 -
|
|
182
|
+
* rows with auto=0 exist only as legacy data from the claimed-names era.
|
|
183
|
+
*/
|
|
184
|
+
register(
|
|
185
|
+
sessionID: string,
|
|
186
|
+
project: string | null,
|
|
187
|
+
topic: string | null,
|
|
188
|
+
kind: ChatHostKind,
|
|
189
|
+
nameBase: string | null = null,
|
|
190
|
+
): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
|
|
191
|
+
const existing = this.#find(sessionID);
|
|
192
|
+
if (existing) {
|
|
193
|
+
this.#touch(sessionID);
|
|
194
|
+
// An explicit registration clears the leave tombstone for its own
|
|
195
|
+
// session ID: "I want back in" is the opposite of "I left". This is
|
|
196
|
+
// also what closes the in-flight race - an auto-register IIFE whose
|
|
197
|
+
// session was deleted mid-await finds the tombstone here and stops.
|
|
198
|
+
// The caller distinction is by ID: the idle path re-checks with the
|
|
199
|
+
// session's own ID, so a tombstone set by chat_unregister or
|
|
200
|
+
// session.deleted suppresses exactly that session.
|
|
201
|
+
const gate = this.#tombstoneGate(sessionID, kind);
|
|
202
|
+
if (gate) return gate;
|
|
203
|
+
return { ok: true, name: existing.name, topic: existing.topic };
|
|
204
|
+
}
|
|
205
|
+
const gate = this.#tombstoneGate(sessionID, kind);
|
|
206
|
+
if (gate) return gate;
|
|
207
|
+
const cleanTopic = this.#cleanTopic(topic);
|
|
208
|
+
const base =
|
|
209
|
+
nameBase ??
|
|
210
|
+
slugifyTitle(CHAT_NAME_POOL[Math.floor(Math.random() * CHAT_NAME_POOL.length)]) ??
|
|
211
|
+
"session";
|
|
212
|
+
// The counter draw is atomic, but the name INSERT can still lose to a
|
|
213
|
+
// legacy row that already holds that exact name (custom claims from
|
|
214
|
+
// before names were assigned). Each retry bumps the counter, so the
|
|
215
|
+
// loop always makes forward progress and terminates.
|
|
216
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
217
|
+
const next = this.#bumpCounter(base);
|
|
218
|
+
const name = `${base}-${String(next).padStart(5, "0")}`;
|
|
219
|
+
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
|
|
220
|
+
if (claimed.ok) return { ok: true, name, topic: cleanTopic };
|
|
221
|
+
if (claimed.reason === "already-registered") {
|
|
222
|
+
// A concurrent registration of the same session ID won the race;
|
|
223
|
+
// report its row rather than erroring.
|
|
224
|
+
const winner = this.#find(sessionID);
|
|
225
|
+
if (winner) return { ok: true, name: winner.name, topic: winner.topic };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ok: false, error: "Could not assign a unique name after several attempts." };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Leaves the directory. Messages already sent to or from the session are
|
|
232
|
+
* kept as history; departed senders show as unknown names to readers. */
|
|
233
|
+
unregister(sessionID: string): boolean {
|
|
234
|
+
const existing = this.#find(sessionID);
|
|
235
|
+
if (!existing) return false;
|
|
236
|
+
this.#db.transaction(() => {
|
|
237
|
+
// Tombstone before delete: auto-registration keys on the row being
|
|
238
|
+
// absent, so without a marker the next idle event would silently
|
|
239
|
+
// re-register the session under a brand-new name and the user's
|
|
240
|
+
// "leave the directory" would last until the turn ended. The
|
|
241
|
+
// tombstone survives the delete and suppresses auto-registration
|
|
242
|
+
// until the session explicitly rejoins via chat_register (which
|
|
243
|
+
// clears it). Both leave paths come through here - chat_unregister
|
|
244
|
+
// AND session.deleted (a deleted TUI session is an exit too; without
|
|
245
|
+
// the tombstone an in-flight auto-register could resurrect it).
|
|
246
|
+
this.#db.run("INSERT OR REPLACE INTO chat_leave_tombstones (session_id, left_at) VALUES (?, ?)", [
|
|
247
|
+
sessionID,
|
|
248
|
+
nowIso(),
|
|
249
|
+
]);
|
|
250
|
+
this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
|
|
251
|
+
})();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether the session explicitly left the directory and has not
|
|
256
|
+
* rejoined. Auto-registration consults this before inserting; manual
|
|
257
|
+
* registration (chat_register) clears the tombstone on join. */
|
|
258
|
+
hasLeaveTombstone(sessionID: string): boolean {
|
|
259
|
+
const row = this.#db
|
|
260
|
+
.query("SELECT 1 FROM chat_leave_tombstones WHERE session_id = ?")
|
|
261
|
+
.get(sessionID);
|
|
262
|
+
return row !== null && row !== undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Clears the leave tombstone (chat_register rejoin path). */
|
|
266
|
+
clearLeaveTombstone(sessionID: string): void {
|
|
267
|
+
this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Whether the leave tombstone for `sessionID` has been superseded: a
|
|
272
|
+
* newer registration exists in the same project (registered after the
|
|
273
|
+
* leave). The hook uses this to stop announcing a stale leave once the
|
|
274
|
+
* model has rejoined under a fresh identity - the tombstoned identity
|
|
275
|
+
* and the fresh one cannot be linked, but a newer join in the same
|
|
276
|
+
* project is evidence the conversation moved on.
|
|
277
|
+
*/
|
|
278
|
+
leaveSuperseded(sessionID: string, project: string | null): boolean {
|
|
279
|
+
if (!project) return false;
|
|
280
|
+
const row = this.#db
|
|
281
|
+
.query(
|
|
282
|
+
`SELECT 1 FROM chat_leave_tombstones t
|
|
283
|
+
JOIN chat_sessions cs ON cs.project = ? AND cs.registered_at > t.left_at
|
|
284
|
+
WHERE t.session_id = ?
|
|
285
|
+
LIMIT 1`,
|
|
286
|
+
)
|
|
287
|
+
.get(project, sessionID);
|
|
288
|
+
return row !== null && row !== undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Refreshes an auto-registered session's topic from its current title.
|
|
293
|
+
* opencode's auto-titler usually lands a real title within the first turn
|
|
294
|
+
* or two, so this converges the roster annotation to the actual work.
|
|
295
|
+
* Manual (legacy) rows are never touched - the topic column on those is
|
|
296
|
+
* user/model-set, not title-derived.
|
|
297
|
+
*/
|
|
298
|
+
refreshAutoTopic(sessionID: string, title: string): void {
|
|
299
|
+
const clean = this.#cleanTopic(title);
|
|
300
|
+
if (!clean) return;
|
|
301
|
+
this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ? AND auto = 1 AND topic IS NOT ?", [
|
|
302
|
+
clean,
|
|
303
|
+
sessionID,
|
|
304
|
+
clean,
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Removes auto-registered directory rows whose host has not heartbeat-ed
|
|
310
|
+
* them since `cutoff` (ISO), plus unread mail addressed to sessions no
|
|
311
|
+
* longer in the directory. The mail delete is bounded by the same
|
|
312
|
+
* cutoff: unread mail newer than the cutoff survives this sweep and is
|
|
313
|
+
* removed by a later one once it ages past the moving cutoff - the
|
|
314
|
+
* cutoff is a conservative bound, not an exact age match. Delivered/read
|
|
315
|
+
* history and mail from pruned senders to live recipients are kept -
|
|
316
|
+
* departed senders already render as unknown names. Counters are NOT
|
|
317
|
+
* touched: never decrementing is what makes reissued names impossible.
|
|
318
|
+
* Returns the number of directory rows pruned.
|
|
319
|
+
*/
|
|
320
|
+
pruneStaleAuto(cutoff: string): number {
|
|
321
|
+
const result = this.#db.run("DELETE FROM chat_sessions WHERE auto = 1 AND last_seen < ?", [cutoff]);
|
|
322
|
+
this.#db.run(
|
|
323
|
+
"DELETE FROM chat_messages WHERE read_at IS NULL AND to_session NOT IN (SELECT session_id FROM chat_sessions) AND created_at < ?",
|
|
324
|
+
[cutoff],
|
|
325
|
+
);
|
|
326
|
+
// Tombstones age out too: they are leave markers, not identity records.
|
|
327
|
+
// A resumed opencode session whose tombstone expired simply
|
|
328
|
+
// auto-registers under a fresh name - the expiry window is the
|
|
329
|
+
// grace period for that. Growth is bounded (one row per distinct
|
|
330
|
+
// session), but the sweep keeps the table from growing monotonically
|
|
331
|
+
// forever.
|
|
332
|
+
this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
|
|
333
|
+
return result.changes;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
list(): ChatSessionRow[] {
|
|
337
|
+
return (this.#db
|
|
338
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
|
|
339
|
+
.all() as any[]).map(rowFromSession);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Resolves a recipient by display name (case-insensitive) or session ID.
|
|
343
|
+
* Name-first is safe only because opencode session IDs contain
|
|
344
|
+
* underscores and NAME_CHARSET excludes them, so no legal display name
|
|
345
|
+
* can shadow an ID; if either format ever changes, resolve IDs first. */
|
|
346
|
+
find(nameOrID: string): ChatSessionRow | null {
|
|
347
|
+
return this.#findByName(nameOrID) ?? this.#find(nameOrID);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Posts a message. Both endpoints must be registered; a session cannot
|
|
352
|
+
* message itself (nothing is coordinated: the message would sit unread
|
|
353
|
+
* until the sender drains its own inbox, and the wake prompt would be the
|
|
354
|
+
* sender nudging itself). Returns the resolved recipient so callers never
|
|
355
|
+
* need a second lookup - a re-lookup could race a concurrent unregister
|
|
356
|
+
* and fail after the message already landed.
|
|
357
|
+
*/
|
|
358
|
+
send(
|
|
359
|
+
fromSession: string,
|
|
360
|
+
toNameOrID: string,
|
|
361
|
+
body: string,
|
|
362
|
+
): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
|
|
363
|
+
const trimmed = body.trim();
|
|
364
|
+
if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
|
|
365
|
+
if (trimmed.length > MAX_BODY_LEN) {
|
|
366
|
+
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
367
|
+
}
|
|
368
|
+
const sender = this.#find(fromSession);
|
|
369
|
+
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
370
|
+
const recipient = this.find(toNameOrID);
|
|
371
|
+
if (!recipient) {
|
|
372
|
+
return { ok: false, error: `No registered session named "${toNameOrID}". Use chat_list to see who is available.` };
|
|
373
|
+
}
|
|
374
|
+
if (recipient.session_id === fromSession) {
|
|
375
|
+
return { ok: false, error: "You cannot message yourself." };
|
|
376
|
+
}
|
|
377
|
+
this.#db.run(
|
|
378
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
|
|
379
|
+
[fromSession, recipient.session_id, trimmed, nowIso()],
|
|
380
|
+
);
|
|
381
|
+
return { ok: true, recipient: { session_id: recipient.session_id, name: recipient.name } };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Posts a message to every other registered session at once. Stale
|
|
386
|
+
* sessions are skipped, not messaged - a host process that has stopped
|
|
387
|
+
* heartbeat-ing will never read the mail, and a broadcast is for reaching
|
|
388
|
+
* live agents. Each recipient gets its own inbox row, so the existing
|
|
389
|
+
* wake machinery (grouping, gating, rate cap) treats the broadcast as
|
|
390
|
+
* ordinary per-recipient mail.
|
|
391
|
+
*/
|
|
392
|
+
broadcast(
|
|
393
|
+
fromSession: string,
|
|
394
|
+
body: string,
|
|
395
|
+
): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
|
|
396
|
+
const trimmed = body.trim();
|
|
397
|
+
if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
|
|
398
|
+
if (trimmed.length > MAX_BODY_LEN) {
|
|
399
|
+
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
400
|
+
}
|
|
401
|
+
const sender = this.#find(fromSession);
|
|
402
|
+
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
403
|
+
const recipients: string[] = [];
|
|
404
|
+
const skipped: string[] = [];
|
|
405
|
+
// One transaction: a crash mid-loop rolls the whole fan-out back
|
|
406
|
+
// instead of leaving a partial broadcast where some recipients got the
|
|
407
|
+
// message and others silently did not.
|
|
408
|
+
this.#db.transaction(() => {
|
|
409
|
+
for (const row of this.list()) {
|
|
410
|
+
if (row.session_id === fromSession) continue;
|
|
411
|
+
// Staleness means "host process gone" only for opencode rows; an
|
|
412
|
+
// mcp row between turns reads its mail at the next prompt, so it
|
|
413
|
+
// always receives.
|
|
414
|
+
if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
|
|
415
|
+
skipped.push(row.name);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
this.#db.run(
|
|
419
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
|
|
420
|
+
[fromSession, row.session_id, trimmed, nowIso()],
|
|
421
|
+
);
|
|
422
|
+
recipients.push(row.name);
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
return { ok: true, recipients, skipped };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Drains the calling session's inbox: returns unread messages oldest-first
|
|
430
|
+
* and stamps exactly those rows read. The stamp is scoped to the selected
|
|
431
|
+
* ids, not re-derived - a message arriving from another process between
|
|
432
|
+
* the SELECT and the UPDATE must stay unread so the poller re-nudges it;
|
|
433
|
+
* an unscoped stamp would swallow it silently.
|
|
434
|
+
*/
|
|
435
|
+
read(sessionID: string): ChatInboxItem[] {
|
|
436
|
+
const rows = this.#db
|
|
437
|
+
.query(
|
|
438
|
+
`SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
|
|
439
|
+
FROM chat_messages m
|
|
440
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
441
|
+
WHERE m.to_session = ? AND m.read_at IS NULL
|
|
442
|
+
ORDER BY m.id`,
|
|
443
|
+
)
|
|
444
|
+
.all(sessionID) as any[];
|
|
445
|
+
if (rows.length === 0) return [];
|
|
446
|
+
const marks = rows.map(() => "?").join(",");
|
|
447
|
+
this.#db.run(`UPDATE chat_messages SET read_at = ? WHERE id IN (${marks})`, [
|
|
448
|
+
nowIso(),
|
|
449
|
+
...rows.map((r) => r.id),
|
|
450
|
+
]);
|
|
451
|
+
return rows.map((r) => ({
|
|
452
|
+
id: r.id,
|
|
453
|
+
from_session: r.from_session,
|
|
454
|
+
from_name: r.from_name ?? null,
|
|
455
|
+
body: r.body,
|
|
456
|
+
created_at: r.created_at,
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Unread message count for a session, for chat_list's self row. */
|
|
461
|
+
unreadCount(sessionID: string): number {
|
|
462
|
+
const row = this.#db
|
|
463
|
+
.query("SELECT COUNT(*) AS n FROM chat_messages WHERE to_session = ? AND read_at IS NULL")
|
|
464
|
+
.get(sessionID) as any;
|
|
465
|
+
return row.n;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The caller's mailbox summary for chat_status: whether a directory row
|
|
470
|
+
* exists, and pending/total counts. Touches last_seen on use, which is
|
|
471
|
+
* what keeps an active MCP session's roster row fresh between prompt-time
|
|
472
|
+
* checks. An unregistered caller gets registered: false - the explicit,
|
|
473
|
+
* quiet answer that replaces any nudge.
|
|
474
|
+
*/
|
|
475
|
+
status(sessionID: string): { registered: true; name: string; pending: number; total: number } | { registered: false } {
|
|
476
|
+
const row = this.#find(sessionID);
|
|
477
|
+
if (!row) return { registered: false };
|
|
478
|
+
this.#touch(sessionID);
|
|
479
|
+
const counts = this.#db
|
|
480
|
+
.query(
|
|
481
|
+
`SELECT COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS pending
|
|
482
|
+
FROM chat_messages WHERE to_session = ?`,
|
|
483
|
+
)
|
|
484
|
+
.get(sessionID) as any;
|
|
485
|
+
return {
|
|
486
|
+
registered: true,
|
|
487
|
+
name: row.name,
|
|
488
|
+
pending: counts.pending ?? 0,
|
|
489
|
+
total: counts.total ?? 0,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Pending-mail summary for the hook channel (flush-tools / reminder):
|
|
495
|
+
* every registered session in the project with unread mail, with its
|
|
496
|
+
* display name and unread count. The hook prints this only when
|
|
497
|
+
* non-empty, and names the addressee - a session that never registered
|
|
498
|
+
* never sees a chat line at all.
|
|
499
|
+
*/
|
|
500
|
+
pendingByProject(project: string): Array<{ name: string; pending: number }> {
|
|
501
|
+
return (
|
|
502
|
+
this.#db
|
|
503
|
+
.query(
|
|
504
|
+
`SELECT cs.name AS name, COUNT(*) AS pending
|
|
505
|
+
FROM chat_messages m
|
|
506
|
+
JOIN chat_sessions cs ON cs.session_id = m.to_session
|
|
507
|
+
WHERE m.read_at IS NULL AND cs.project = ?
|
|
508
|
+
GROUP BY cs.name
|
|
509
|
+
ORDER BY cs.name`,
|
|
510
|
+
)
|
|
511
|
+
.all(project) as any[]
|
|
512
|
+
).map((r) => ({ name: r.name, pending: r.pending }));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Refreshes last_seen for the given sessions. Sessions that never
|
|
516
|
+
* registered are absent from chat_sessions, so the UPDATE is a no-op for
|
|
517
|
+
* them - no filtering needed on the caller's side. */
|
|
518
|
+
heartbeat(sessionIDs: string[]): void {
|
|
519
|
+
if (sessionIDs.length === 0) return;
|
|
520
|
+
const marks = sessionIDs.map(() => "?").join(",");
|
|
521
|
+
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
|
|
522
|
+
nowIso(),
|
|
523
|
+
...sessionIDs,
|
|
524
|
+
]);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The full message feed for `thatch chat tail`: every row with sender and
|
|
529
|
+
* recipient display names resolved (departed senders degrade to unknown),
|
|
530
|
+
* oldest first. The CLI diffs consecutive snapshots to emit sent and
|
|
531
|
+
* read events; the table is machine-scale, so a full scan per poll is
|
|
532
|
+
* cheap.
|
|
533
|
+
*/
|
|
534
|
+
messageFeed(): Array<{
|
|
535
|
+
id: number;
|
|
536
|
+
from: string | null;
|
|
537
|
+
to: string | null;
|
|
538
|
+
fromTopic: string | null;
|
|
539
|
+
toTopic: string | null;
|
|
540
|
+
viaBroadcast: boolean;
|
|
541
|
+
body: string;
|
|
542
|
+
created_at: string;
|
|
543
|
+
read_at: string | null;
|
|
544
|
+
}> {
|
|
545
|
+
return (
|
|
546
|
+
this.#db
|
|
547
|
+
.query(
|
|
548
|
+
`SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, fs.topic AS from_topic, ts.topic AS to_topic, m.via_broadcast, m.body, m.created_at, m.read_at
|
|
549
|
+
FROM chat_messages m
|
|
550
|
+
LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
|
|
551
|
+
LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
|
|
552
|
+
ORDER BY m.id`,
|
|
553
|
+
)
|
|
554
|
+
.all() as any[]
|
|
555
|
+
).map((r) => ({
|
|
556
|
+
id: r.id,
|
|
557
|
+
from: renderChatParticipant(r.from_name, r.from_session),
|
|
558
|
+
to: renderChatParticipant(r.to_name, r.to_session),
|
|
559
|
+
fromTopic: r.from_topic ?? null,
|
|
560
|
+
toTopic: r.to_topic ?? null,
|
|
561
|
+
viaBroadcast: r.via_broadcast === 1,
|
|
562
|
+
body: r.body,
|
|
563
|
+
created_at: r.created_at,
|
|
564
|
+
read_at: r.read_at ?? null,
|
|
565
|
+
}));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Messages that need a wake prompt for the given sessions: unread and
|
|
570
|
+
* either never delivered, or delivered so long ago that a re-nudge is due.
|
|
571
|
+
* Only registered recipients are selected - unregistering must stop wake
|
|
572
|
+
* prompts for kept-but-unread mail, which is exactly what the
|
|
573
|
+
* chat_unregister tool promises. The cutoff is passed by the poller (its
|
|
574
|
+
* renudge window, as an ISO timestamp); delivered_at comparison is string
|
|
575
|
+
* comparison, which works because every timestamp in these tables uses
|
|
576
|
+
* the same second-resolution format.
|
|
577
|
+
*/
|
|
578
|
+
pendingNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[] {
|
|
579
|
+
if (sessionIDs.length === 0) return [];
|
|
580
|
+
const marks = sessionIDs.map(() => "?").join(",");
|
|
581
|
+
return (
|
|
582
|
+
this.#db
|
|
583
|
+
.query(
|
|
584
|
+
`SELECT m.id, m.to_session, m.from_session, s.name AS from_name
|
|
585
|
+
FROM chat_messages m
|
|
586
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
587
|
+
WHERE m.read_at IS NULL
|
|
588
|
+
AND m.to_session IN (${marks})
|
|
589
|
+
AND (m.delivered_at IS NULL OR m.delivered_at <= ?)
|
|
590
|
+
AND EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.session_id = m.to_session)
|
|
591
|
+
ORDER BY m.id`,
|
|
592
|
+
)
|
|
593
|
+
.all(...sessionIDs, renudgeCutoff) as any[]
|
|
594
|
+
).map((r) => ({
|
|
595
|
+
id: r.id,
|
|
596
|
+
to_session: r.to_session,
|
|
597
|
+
from_session: r.from_session,
|
|
598
|
+
from_name: r.from_name ?? null,
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Stamps delivered_at on the given messages. Called after a wake prompt
|
|
603
|
+
* was accepted; re-stamping on later nudges resets the re-nudge timer. */
|
|
604
|
+
markDelivered(ids: number[]): void {
|
|
605
|
+
if (ids.length === 0) return;
|
|
606
|
+
const marks = ids.map(() => "?").join(",");
|
|
607
|
+
this.#db.run(`UPDATE chat_messages SET delivered_at = ? WHERE id IN (${marks})`, [nowIso(), ...ids]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#find(sessionID: string): ChatSessionRow | null {
|
|
611
|
+
const row = this.#db
|
|
612
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
|
|
613
|
+
.get(sessionID) as any;
|
|
614
|
+
return row ? rowFromSession(row) : null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
#findByName(name: string): ChatSessionRow | null {
|
|
618
|
+
const row = this.#db
|
|
619
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
620
|
+
.get(name) as any;
|
|
621
|
+
return row ? rowFromSession(row) : null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#touch(sessionID: string): void {
|
|
625
|
+
this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Collapses whitespace runs (topics must stay one roster line) and caps
|
|
629
|
+
* the length. The null/empty distinction is load-bearing: null means
|
|
630
|
+
* "omit on re-register, keep the existing topic", while empty or
|
|
631
|
+
* whitespace-only means "clear it" - so this never turns one into the
|
|
632
|
+
* other. Oversized topics are capped, never an error: a topic is
|
|
633
|
+
* advisory. */
|
|
634
|
+
#cleanTopic(topic: string | null): string | null {
|
|
635
|
+
if (topic === null) return null;
|
|
636
|
+
return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The leave-tombstone gate, applied on both register() paths (existing
|
|
641
|
+
* row and fresh mint). Policy: an opencode registration is the plugin's
|
|
642
|
+
* auto-registration, so a tombstone suppresses it outright - the in-
|
|
643
|
+
* flight IIFE whose session was deleted mid-await lands here and stops
|
|
644
|
+
* instead of resurrecting a dead session as a roster corpse. An MCP
|
|
645
|
+
* registration over the hook-anchored ID is a genuine rejoin (the
|
|
646
|
+
* derived session ID matches the tombstone), so the gate clears it and
|
|
647
|
+
* lets the registration proceed. Defense in depth: no current
|
|
648
|
+
* production caller reaches the MCP branch with a tombstoned ID (the
|
|
649
|
+
* tool's rejoin clears at its own layer first); UC-101 pins the DB
|
|
650
|
+
* contract. Returns the failure result when the gate refuses, or null
|
|
651
|
+
* to proceed.
|
|
652
|
+
*/
|
|
653
|
+
#tombstoneGate(sessionID: string, kind: ChatHostKind): { ok: false; error: string } | null {
|
|
654
|
+
if (!this.hasLeaveTombstone(sessionID)) return null;
|
|
655
|
+
if (kind === "opencode") {
|
|
656
|
+
return { ok: false, error: "leave tombstone active" };
|
|
657
|
+
}
|
|
658
|
+
this.clearLeaveTombstone(sessionID);
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Atomically draws the next counter value for a name base: the INSERT
|
|
664
|
+
* ... ON CONFLICT upsert seeds the counter at 2 on first draw and
|
|
665
|
+
* increments it on every later draw, returning the drawn value in one
|
|
666
|
+
* statement - two processes racing the same base can never draw the same
|
|
667
|
+
* number. Counters only ever increment (prune included), which is what
|
|
668
|
+
* makes assigned names immortal.
|
|
669
|
+
*/
|
|
670
|
+
#bumpCounter(base: string): number {
|
|
671
|
+
// The insert seeds the counter at 2 (1 is being handed out now) so the
|
|
672
|
+
// stored value always reads as the next unassigned number; the RETURNING
|
|
673
|
+
// hands out the drawn value. One statement, race-safe.
|
|
674
|
+
const row = this.#db
|
|
675
|
+
.query(
|
|
676
|
+
"INSERT INTO chat_name_counters (base, next) VALUES (?, 2) ON CONFLICT(base) DO UPDATE SET next = next + 1 RETURNING next - 1 AS drawn",
|
|
677
|
+
)
|
|
678
|
+
.get(base) as { drawn: number };
|
|
679
|
+
return row.drawn;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* INSERTs a directory row as auto=1 (the only rows this class mints;
|
|
684
|
+
* auto=0 rows are legacy data). Constraint violations are translated
|
|
685
|
+
* into structured outcomes rather than raw SQLite errors: a UNIQUE hit
|
|
686
|
+
* on name means the counter draw collided with a legacy row holding that
|
|
687
|
+
* exact name (the caller bumps and retries), and a PK hit on session_id
|
|
688
|
+
* means the session row already exists (a concurrent registration won
|
|
689
|
+
* the race).
|
|
690
|
+
*/
|
|
691
|
+
#insertSession(
|
|
692
|
+
sessionID: string,
|
|
693
|
+
name: string,
|
|
694
|
+
project: string | null,
|
|
695
|
+
topic: string | null,
|
|
696
|
+
kind: ChatHostKind,
|
|
697
|
+
): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
|
|
698
|
+
try {
|
|
699
|
+
this.#db.run(
|
|
700
|
+
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
|
|
701
|
+
[sessionID, name, topic, project, kind, nowIso(), nowIso()],
|
|
702
|
+
);
|
|
703
|
+
return { ok: true, topic };
|
|
704
|
+
} catch (err) {
|
|
705
|
+
if (!this.#isConstraintError(err)) throw err;
|
|
706
|
+
const msg = String((err as any)?.message ?? err);
|
|
707
|
+
return msg.includes("chat_sessions.name")
|
|
708
|
+
? { ok: false, reason: "name-taken" }
|
|
709
|
+
: { ok: false, reason: "already-registered" };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#isConstraintError(err: unknown): boolean {
|
|
714
|
+
return String((err as any)?.code ?? err).includes("CONSTRAINT");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function rowFromSession(r: any): ChatSessionRow {
|
|
719
|
+
return {
|
|
720
|
+
session_id: r.session_id,
|
|
721
|
+
name: r.name,
|
|
722
|
+
// An empty-string topic (explicit clear) reads back as no topic, so the
|
|
723
|
+
// API never leaks the sentinel.
|
|
724
|
+
topic: r.topic || null,
|
|
725
|
+
host_kind: r.host_kind === "mcp" ? "mcp" : "opencode",
|
|
726
|
+
project: r.project,
|
|
727
|
+
registered_at: r.registered_at,
|
|
728
|
+
last_seen: r.last_seen,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Renders a chat participant for user-visible surfaces (wake prompts, the
|
|
734
|
+
* tail feed, chat_read): the display name when the directory row still
|
|
735
|
+
* exists, otherwise the shared unknown-departed convention. One helper
|
|
736
|
+
* because the exact string is a cross-surface convention - a reword here
|
|
737
|
+
* must reach every surface or the reader sees two names for the same
|
|
738
|
+
* condition.
|
|
739
|
+
*/
|
|
740
|
+
export function renderChatParticipant(name: string | null, sessionID: string | null | undefined): string {
|
|
741
|
+
if (name) return name;
|
|
742
|
+
return `unknown (${String(sessionID ?? "").slice(0, 12)}, departed)`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
// Tail events (pure logic behind `thatch chat tail`)
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
export type ChatTailEvent =
|
|
750
|
+
| { kind: "sent"; timestamp: string; from: string; to: string; fromTopic: string | null; toTopic: string | null; body: string }
|
|
751
|
+
| { kind: "read"; timestamp: string; reader: string; from: string; fromTopic: string | null; toTopic: string | null; body: string };
|
|
752
|
+
|
|
753
|
+
/** One message row as the tail diff consumes it. */
|
|
754
|
+
export interface ChatTailRow {
|
|
755
|
+
id: number;
|
|
756
|
+
from: string | null;
|
|
757
|
+
to: string | null;
|
|
758
|
+
fromTopic: string | null;
|
|
759
|
+
toTopic: string | null;
|
|
760
|
+
viaBroadcast: boolean;
|
|
761
|
+
body: string;
|
|
762
|
+
created_at: string;
|
|
763
|
+
read_at: string | null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Diffs one feed snapshot against the previous one, emitting sent events
|
|
768
|
+
* for rows the poller has not seen and read events for rows whose read_at
|
|
769
|
+
* appeared since the last poll. Pure: the caller owns the state map (start
|
|
770
|
+
* empty for a fresh tail) and updates it from the returned state. A message
|
|
771
|
+
* inserted and read between two polls emits only its sent line - the sent
|
|
772
|
+
* event is the primary record, and the read of a message nobody saw as sent
|
|
773
|
+
* adds nothing.
|
|
774
|
+
*/
|
|
775
|
+
export function chatTailDiff(
|
|
776
|
+
prev: Map<number, string | null>,
|
|
777
|
+
rows: Array<ChatTailRow>,
|
|
778
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null> } {
|
|
779
|
+
const events: ChatTailEvent[] = [];
|
|
780
|
+
for (const r of rows) {
|
|
781
|
+
const isNew = !prev.has(r.id);
|
|
782
|
+
if (isNew) {
|
|
783
|
+
events.push({
|
|
784
|
+
kind: "sent",
|
|
785
|
+
timestamp: r.created_at,
|
|
786
|
+
from: r.from ?? "unknown",
|
|
787
|
+
to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
|
|
788
|
+
fromTopic: r.fromTopic,
|
|
789
|
+
toTopic: r.viaBroadcast ? null : r.toTopic,
|
|
790
|
+
body: r.body,
|
|
791
|
+
});
|
|
792
|
+
} else {
|
|
793
|
+
const prevRead = prev.get(r.id) ?? null;
|
|
794
|
+
if (r.read_at !== null && prevRead === null) {
|
|
795
|
+
events.push({
|
|
796
|
+
kind: "read",
|
|
797
|
+
timestamp: r.read_at,
|
|
798
|
+
reader: r.to ?? "unknown",
|
|
799
|
+
from: r.from ?? "unknown",
|
|
800
|
+
fromTopic: r.fromTopic,
|
|
801
|
+
toTopic: r.toTopic,
|
|
802
|
+
body: r.body,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
prev.set(r.id, r.read_at);
|
|
807
|
+
}
|
|
808
|
+
return { events, state: prev };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The default number of backlog cards `thatch chat tail` renders before
|
|
813
|
+
* follow mode takes over. The message table is machine-scale but the
|
|
814
|
+
* history still grows forever, so an unbounded default would dump the
|
|
815
|
+
* entire database on every run.
|
|
816
|
+
*/
|
|
817
|
+
export const CHAT_TAIL_DEFAULT_LIMIT = 20;
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Parses a tail time-bound value into epoch milliseconds, interpreted in
|
|
821
|
+
* the terminal's local timezone (the same clock the When headers print).
|
|
822
|
+
* Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
|
|
823
|
+
* space or a T between the date and the time. The component round-trip
|
|
824
|
+
* rejects impossible calendar values like 2026-09-31 and 25:99, which
|
|
825
|
+
* JavaScript's Date would otherwise silently normalize.
|
|
826
|
+
*/
|
|
827
|
+
export function parseChatTimeBound(value: string, flag: string): number {
|
|
828
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?$/.exec(value.trim());
|
|
829
|
+
if (!m) {
|
|
830
|
+
throw new Error(`${flag} expects "YYYY-MM-DD" or "YYYY-MM-DD HH:MM", got "${value}"`);
|
|
831
|
+
}
|
|
832
|
+
const y = Number(m[1]);
|
|
833
|
+
const mo = Number(m[2]);
|
|
834
|
+
const d = Number(m[3]);
|
|
835
|
+
const h = Number(m[4] ?? 0);
|
|
836
|
+
const mi = Number(m[5] ?? 0);
|
|
837
|
+
const date = new Date(y, mo - 1, d, h, mi);
|
|
838
|
+
// Date normalizes out-of-range components (month 13, hour 25); the
|
|
839
|
+
// round-trip is the check that the value was real.
|
|
840
|
+
if (!roundTrip(date, y, mo, d, h, mi)) {
|
|
841
|
+
throw new Error(`${flag} is not a real date or time: "${value}"`);
|
|
842
|
+
}
|
|
843
|
+
return date.getTime();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi: number): boolean {
|
|
847
|
+
return (
|
|
848
|
+
date.getFullYear() === y &&
|
|
849
|
+
date.getMonth() === mo - 1 &&
|
|
850
|
+
date.getDate() === d &&
|
|
851
|
+
date.getHours() === h &&
|
|
852
|
+
date.getMinutes() === mi
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Everything the tail can narrow a feed by, all ANDed together. `matches`
|
|
858
|
+
* test the message body; `fromSubstr`/`toSubstr` are case-insensitive
|
|
859
|
+
* substrings of the rendered participant names (so a departed sender can
|
|
860
|
+
* only match as "unknown (...)", the string the reader actually sees).
|
|
861
|
+
* One deliberate asymmetry: broadcast rows keep the real recipient in
|
|
862
|
+
* `to`, so `--to <realname>` matches fan-out rows even though their cards
|
|
863
|
+
* say "broadcast", and `--to broadcast` matches nothing. `sinceMs`/
|
|
864
|
+
* `untilMs` bound created_at in a since-inclusive, until-exclusive
|
|
865
|
+
* window. An empty field is no constraint.
|
|
866
|
+
*
|
|
867
|
+
* Name inputs are not stable across polls: they are re-resolved from the
|
|
868
|
+
* live directory on every feed snapshot, so an unregister or rename
|
|
869
|
+
* flips which rows match. chatTailBacklog's full-feed state seeding is
|
|
870
|
+
* what keeps such flips from resurfacing old rows as sent events; reads
|
|
871
|
+
* of rows whose name stopped matching simply go quiet.
|
|
872
|
+
*/
|
|
873
|
+
export interface ChatTailFilter {
|
|
874
|
+
matches: RegExp[];
|
|
875
|
+
fromSubstr: string[];
|
|
876
|
+
toSubstr: string[];
|
|
877
|
+
sinceMs: number | null;
|
|
878
|
+
untilMs: number | null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Filters a feed snapshot down to the rows the tail should consider. */
|
|
882
|
+
export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFilter): Array<ChatTailRow> {
|
|
883
|
+
const froms = filter.fromSubstr.map((s) => s.toLowerCase());
|
|
884
|
+
const tos = filter.toSubstr.map((s) => s.toLowerCase());
|
|
885
|
+
return rows.filter((r) => {
|
|
886
|
+
const t = Date.parse(r.created_at);
|
|
887
|
+
if (filter.sinceMs !== null && t < filter.sinceMs) return false;
|
|
888
|
+
if (filter.untilMs !== null && t >= filter.untilMs) return false;
|
|
889
|
+
if (!filter.matches.every((re) => re.test(r.body))) return false;
|
|
890
|
+
if (froms.some((s) => !(r.from ?? "").toLowerCase().includes(s))) return false;
|
|
891
|
+
if (tos.some((s) => !(r.to ?? "").toLowerCase().includes(s))) return false;
|
|
892
|
+
return true;
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Prepares the tail's first render. The diff state seeds from EVERY row in
|
|
898
|
+
* the feed - filtered or not, rendered or not - for two reasons. First,
|
|
899
|
+
* the limit hides cards, not history: a state seeded only from the
|
|
900
|
+
* rendered slice would re-emit the elided history as new sent events on
|
|
901
|
+
* the first follow poll. Second, the name filters test JOIN-resolved
|
|
902
|
+
* participant names that can flip mid-follow (a peer unregisters and its
|
|
903
|
+
* history starts rendering as "unknown (... departed)", or it re-registers
|
|
904
|
+
* under a new name); seeding everything means an old row can never resurface
|
|
905
|
+
* as a sent event no matter how its rendered name changes. The last
|
|
906
|
+
* `limit` filtered rows (null = all) shape into sent events via
|
|
907
|
+
* chatTailDiff, so card rendering keeps a single source. `elided` counts
|
|
908
|
+
* matching rows the limit hid, for the CLI's summary line.
|
|
909
|
+
*
|
|
910
|
+
* The caller must re-apply the same filter to every follow poll's feed
|
|
911
|
+
* BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
|
|
912
|
+
* returned here). Skipping the poll filter resurrects non-matching
|
|
913
|
+
* history; mutating the state between polls corrupts the read diff.
|
|
914
|
+
*/
|
|
915
|
+
export function chatTailBacklog(
|
|
916
|
+
rows: Array<ChatTailRow>,
|
|
917
|
+
filter: ChatTailFilter,
|
|
918
|
+
limit: number | null,
|
|
919
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
|
|
920
|
+
const state = new Map(rows.map((r) => [r.id, r.read_at]));
|
|
921
|
+
const filtered = filterChatTailRows(rows, filter);
|
|
922
|
+
const shown = limit === null ? filtered : filtered.slice(-limit);
|
|
923
|
+
const events = chatTailDiff(new Map(), shown).events;
|
|
924
|
+
return { events, state, elided: filtered.length - shown.length };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Renders one tail event in the CLI's line format. */
|
|
928
|
+
export function formatChatTailEvent(event: ChatTailEvent): string {
|
|
929
|
+
if (event.kind === "sent") {
|
|
930
|
+
return `[${event.timestamp}] ${event.from} -> ${event.to}: ${event.body}`;
|
|
931
|
+
}
|
|
932
|
+
const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
|
|
933
|
+
return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** "2026-09-12 19:46 MT" - the UTC timestamp converted to the terminal's
|
|
937
|
+
* local timezone (abbreviation from the locale, fallback "UTC"), one line
|
|
938
|
+
* for the card header. */
|
|
939
|
+
function localWhen(timestamp: string): string {
|
|
940
|
+
const date = new Date(timestamp);
|
|
941
|
+
if (Number.isNaN(date.getTime())) return timestamp;
|
|
942
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
943
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
944
|
+
return (
|
|
945
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
946
|
+
`${pad(date.getHours())}:${pad(date.getMinutes())} ${tz}`
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Renders one tail event as a chat card: header block (From / To / When),
|
|
952
|
+
* blank line, full body, then a separator line. This is the human format
|
|
953
|
+
* for `thatch chat tail` - the single-line formatChatTailEvent remains for
|
|
954
|
+
* tests and compact contexts. Read events use the same card shape with the
|
|
955
|
+
* reader in the From slot; broadcast events show "broadcast" as the
|
|
956
|
+
* recipient. The separator is between cards, so the caller joins cards
|
|
957
|
+
* with it and never gets a trailing rule.
|
|
958
|
+
*/
|
|
959
|
+
// ANSI styling for the tail cards: black-on-green field-label chips, green
|
|
960
|
+
// values, dim for the timestamps and the "read" marker, and a drawn rule
|
|
961
|
+
// for the separator. Bare ESC sequences rather than a color library - the
|
|
962
|
+
// tail is a terminal surface, and the codes are trivial.
|
|
963
|
+
const ANSI = {
|
|
964
|
+
// Label chips are padded to a fixed width (5 + padding = 7 columns) so
|
|
965
|
+
// every line's content starts at the same column - To's 2-char label
|
|
966
|
+
// misaligned against 4-char labels without this.
|
|
967
|
+
labelBg: (s: string) => `\x1b[30;42m ${s.padEnd(5)} \x1b[0m`,
|
|
968
|
+
valueFg: (s: string) => `\x1b[32m${s}\x1b[0m`, // green (field values)
|
|
969
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim ("read" marker)
|
|
970
|
+
rule: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim (the drawn separator)
|
|
971
|
+
// Session-of-origin annotation: italic + bright-black (muted gray).
|
|
972
|
+
originLabel: (s: string) => `\x1b[3;90m<${s}>\x1b[0m`,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const CARD_INDENT = ""; // cards sit flush with the separator (no extra indent)
|
|
976
|
+
|
|
977
|
+
export function formatChatTailCard(event: ChatTailEvent): string {
|
|
978
|
+
const lines: string[] = [];
|
|
979
|
+
const when = ANSI.dim(localWhen(event.timestamp));
|
|
980
|
+
// The session-of-origin annotation: the participant's registered topic,
|
|
981
|
+
// italic + muted bright-black, after the name. Omitted for participants
|
|
982
|
+
// without a topic (and for the broadcast pseudo-recipient, whose topic
|
|
983
|
+
// is null by construction).
|
|
984
|
+
const origin = (topic: string | null) => (topic ? ` ${ANSI.originLabel(topic)}` : "");
|
|
985
|
+
const chip = (label: string, value: string, topic: string | null) =>
|
|
986
|
+
`${CARD_INDENT}${ANSI.labelBg(label)} ${ANSI.valueFg(value)}${origin(topic)}`;
|
|
987
|
+
if (event.kind === "sent") {
|
|
988
|
+
lines.push(chip("From", event.from, event.fromTopic));
|
|
989
|
+
lines.push(chip("To", event.to, event.toTopic));
|
|
990
|
+
lines.push(chip("When", when, null));
|
|
991
|
+
lines.push("");
|
|
992
|
+
lines.push(event.body);
|
|
993
|
+
} else {
|
|
994
|
+
const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
|
|
995
|
+
lines.push(chip("From", event.reader, event.toTopic));
|
|
996
|
+
lines.push(`${CARD_INDENT}${ANSI.dim("read")} ${ANSI.valueFg(event.from)}${origin(event.fromTopic)}`);
|
|
997
|
+
lines.push(chip("When", when, null));
|
|
998
|
+
lines.push("");
|
|
999
|
+
lines.push(clipped);
|
|
1000
|
+
}
|
|
1001
|
+
return lines.join("\n");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
|
|
1005
|
+
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
// Poller
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
/** The poller's narrow view of the store: the three chat methods it needs.
|
|
1011
|
+
* ThatchDB satisfies this structurally through its delegated chat methods.
|
|
1012
|
+
* The interface pins the poller to exactly these dependencies and removes
|
|
1013
|
+
* chat.ts's need to import db.ts at all - db.ts already value-imports
|
|
1014
|
+
* ChatStore, so a reverse import would form a runtime module cycle. A
|
|
1015
|
+
* test that wants a fake store can pass a plain object, though the suite
|
|
1016
|
+
* currently uses real temp-dir databases. */
|
|
1017
|
+
export interface ChatPollerStore {
|
|
1018
|
+
heartbeatChatSessions(sessionIDs: string[]): void;
|
|
1019
|
+
pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
|
|
1020
|
+
markChatDelivered(ids: number[]): void;
|
|
1021
|
+
/** Hourly TTL prune of auto-registered directory rows (and the mail that
|
|
1022
|
+
* can no longer reach a reader). Optional so test fakes stay minimal. */
|
|
1023
|
+
pruneStaleChatAuto?(cutoff: string): number;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export interface ChatPollerOptions {
|
|
1027
|
+
store: ChatPollerStore;
|
|
1028
|
+
/** Session IDs this process has seen events for. Only sessions hosted by
|
|
1029
|
+
* this process can be delivered to - the recipient's host is the single
|
|
1030
|
+
* deliverer, by design. */
|
|
1031
|
+
hostedSessions: () => string[];
|
|
1032
|
+
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
1033
|
+
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
1034
|
+
/** Gate for delivery: true only when the session can accept a proactive
|
|
1035
|
+
* prompt right now (idle, not compacting). May be async - the plugin's
|
|
1036
|
+
* gate verifies against the server's live status, because the event-fed
|
|
1037
|
+
* map can be stale and a wake injected into a running turn is mid-turn
|
|
1038
|
+
* context injection. Undeliverable messages stay pending and retry on
|
|
1039
|
+
* later cycles. */
|
|
1040
|
+
canDeliver: (sessionID: string) => boolean | Promise<boolean>;
|
|
1041
|
+
/** Poll interval. Default 30s - frequent enough that wake prompts feel
|
|
1042
|
+
* prompt, cheap enough that the shared DB sees only a couple of light
|
|
1043
|
+
* statements per cycle per process. */
|
|
1044
|
+
pollIntervalMs?: number;
|
|
1045
|
+
/** A delivered-but-unread message re-queues a nudge after this long.
|
|
1046
|
+
* Default 15 minutes. */
|
|
1047
|
+
renudgeMinutes?: number;
|
|
1048
|
+
/** Hard brake on wake cycles: maximum wake prompts per recipient per hour.
|
|
1049
|
+
* Default 6. In-memory on purpose - it is a loop guard, not accounting. */
|
|
1050
|
+
maxNudgesPerHour?: number;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Background poller: heartbeats hosted sessions, delivers wake prompts for
|
|
1055
|
+
* pending chat messages. Mirrors the watcher registry's shape (start/stop/
|
|
1056
|
+
* dispose, re-entrant guards, pending-retry semantics) but its pending state
|
|
1057
|
+
* lives in SQLite rather than memory, because the sender may be a different
|
|
1058
|
+
* process than the deliverer.
|
|
1059
|
+
*/
|
|
1060
|
+
export class ChatPoller {
|
|
1061
|
+
#opts: Required<ChatPollerOptions>;
|
|
1062
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
1063
|
+
#polling = false;
|
|
1064
|
+
#delivering = false;
|
|
1065
|
+
/** Per-recipient wake-prompt timestamps inside the last hour. */
|
|
1066
|
+
#nudges = new Map<string, number[]>();
|
|
1067
|
+
/** Last TTL prune epoch ms. Auto-registered rows are pruned hourly, not
|
|
1068
|
+
* every cycle - the prune is a maintenance sweep, not delivery. */
|
|
1069
|
+
#lastPrune = 0;
|
|
1070
|
+
|
|
1071
|
+
constructor(opts: ChatPollerOptions) {
|
|
1072
|
+
this.#opts = {
|
|
1073
|
+
pollIntervalMs: CHAT_POLL_INTERVAL_MS,
|
|
1074
|
+
renudgeMinutes: CHAT_RENUDGE_MINUTES,
|
|
1075
|
+
maxNudgesPerHour: CHAT_MAX_NUDGES_PER_HOUR,
|
|
1076
|
+
...opts,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** True while the poller timer is armed. */
|
|
1081
|
+
get running(): boolean {
|
|
1082
|
+
return this.#timer !== null;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
start(): void {
|
|
1086
|
+
if (this.#timer !== null) return;
|
|
1087
|
+
this.#timer = setInterval(() => void this.poll(), this.#opts.pollIntervalMs);
|
|
1088
|
+
// Never keep the host process alive just for the poller - matches the
|
|
1089
|
+
// watcher registry's timer.
|
|
1090
|
+
this.#timer.unref?.();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
stop(): void {
|
|
1094
|
+
if (this.#timer === null) return;
|
|
1095
|
+
clearInterval(this.#timer);
|
|
1096
|
+
this.#timer = null;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Stops polling and drops the nudge counters. The inbox itself is shared
|
|
1100
|
+
* SQLite state and outlives this process by design. */
|
|
1101
|
+
dispose(): void {
|
|
1102
|
+
this.stop();
|
|
1103
|
+
this.#nudges.clear();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* One poll cycle: heartbeat hosted sessions, then deliver whatever is
|
|
1108
|
+
* pending for sessions that can accept a prompt. Re-entrant calls are
|
|
1109
|
+
* dropped, matching the watcher registry - a slow cycle overlapping the
|
|
1110
|
+
* next would double-deliver the same messages. Store failures are caught
|
|
1111
|
+
* here, not just deliver failures: the timer discards the returned
|
|
1112
|
+
* promise, so an escaping SQLite error would surface as an unhandled
|
|
1113
|
+
* rejection in the plugin host process. A failed cycle logs and retries
|
|
1114
|
+
* on the next tick.
|
|
1115
|
+
*/
|
|
1116
|
+
async poll(): Promise<void> {
|
|
1117
|
+
if (this.#polling) return;
|
|
1118
|
+
this.#polling = true;
|
|
1119
|
+
try {
|
|
1120
|
+
const hosted = this.#opts.hostedSessions();
|
|
1121
|
+
this.#opts.store.heartbeatChatSessions(hosted);
|
|
1122
|
+
await this.deliverPending();
|
|
1123
|
+
// Hourly sweep: drop auto-registered rows whose host died more than
|
|
1124
|
+
// CHAT_AUTO_TTL_DAYS ago, plus the unread mail addressed to sessions
|
|
1125
|
+
// no longer in the directory. Automatic registration makes one-shots
|
|
1126
|
+
// and crashed sessions routine, so without this the roster fills
|
|
1127
|
+
// with corpses; counters never decrement, so pruning cannot reissue
|
|
1128
|
+
// a name. The stamp lands before the call, so a throwing prune
|
|
1129
|
+
// retries on the next hourly sweep, not the next cycle.
|
|
1130
|
+
const now = Date.now();
|
|
1131
|
+
if (now - this.#lastPrune > 3_600_000 && this.#opts.store.pruneStaleChatAuto) {
|
|
1132
|
+
this.#lastPrune = now;
|
|
1133
|
+
this.#opts.store.pruneStaleChatAuto(isoMinutesAgo(CHAT_AUTO_TTL_DAYS * 24 * 60));
|
|
1134
|
+
}
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
console.error(`[thatch] chat poll failed: ${err}`);
|
|
1137
|
+
} finally {
|
|
1138
|
+
this.#polling = false;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Delivers pending messages for every hosted session that can accept a
|
|
1144
|
+
* prompt. Sessions that are busy keep their messages pending; the idle
|
|
1145
|
+
* event handler calls this directly so mail lands promptly instead of
|
|
1146
|
+
* waiting for the next cycle. Failures stay pending and retry. Delivery
|
|
1147
|
+
* is at-least-once: a crash after the wake prompt but before
|
|
1148
|
+
* markChatDelivered re-nudges the same batch on the next cycle.
|
|
1149
|
+
*/
|
|
1150
|
+
async deliverPending(): Promise<void> {
|
|
1151
|
+
if (this.#delivering) return;
|
|
1152
|
+
this.#delivering = true;
|
|
1153
|
+
try {
|
|
1154
|
+
const hosted = this.#opts.hostedSessions();
|
|
1155
|
+
const cutoff = isoMinutesAgo(this.#opts.renudgeMinutes);
|
|
1156
|
+
const pending = this.#opts.store.pendingChatNotifications(hosted, cutoff);
|
|
1157
|
+
if (pending.length === 0) return;
|
|
1158
|
+
|
|
1159
|
+
const byRecipient = new Map<string, ChatNotificationRow[]>();
|
|
1160
|
+
for (const msg of pending) {
|
|
1161
|
+
const queue = byRecipient.get(msg.to_session) ?? [];
|
|
1162
|
+
queue.push(msg);
|
|
1163
|
+
byRecipient.set(msg.to_session, queue);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
for (const [recipient, messages] of byRecipient) {
|
|
1167
|
+
if (!(await this.#opts.canDeliver(recipient))) continue;
|
|
1168
|
+
if (this.#nudgeBudget(recipient) <= 0) continue;
|
|
1169
|
+
try {
|
|
1170
|
+
// "unknown (id, departed)" matches chat_read's rendering of a
|
|
1171
|
+
// sender whose directory row is gone, so both surfaces use one
|
|
1172
|
+
// convention for the same condition.
|
|
1173
|
+
const senders = [...new Set(messages.map((m) => renderChatParticipant(m.from_name, m.from_session)))];
|
|
1174
|
+
await this.#opts.deliver(recipient, senders, messages.length);
|
|
1175
|
+
// Count the nudge before the stamp: the cap must bind on prompts
|
|
1176
|
+
// actually sent, not on the DB write succeeding. A failing stamp
|
|
1177
|
+
// leaves the mail pending, and an uncounted re-delivery every
|
|
1178
|
+
// cycle would be the ping-pong the cap exists to stop.
|
|
1179
|
+
this.#recordNudge(recipient);
|
|
1180
|
+
this.#opts.store.markChatDelivered(messages.map((m) => m.id));
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
console.error(`[thatch] chat delivery to ${recipient} failed: ${err}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
} finally {
|
|
1186
|
+
this.#delivering = false;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Remaining wake prompts for this recipient in the current hour. */
|
|
1191
|
+
#nudgeBudget(recipient: string): number {
|
|
1192
|
+
const now = Date.now();
|
|
1193
|
+
const window = this.#nudges.get(recipient) ?? [];
|
|
1194
|
+
const fresh = window.filter((t) => now - t < 3_600_000);
|
|
1195
|
+
this.#nudges.set(recipient, fresh);
|
|
1196
|
+
return this.#opts.maxNudgesPerHour - fresh.length;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
#recordNudge(recipient: string): void {
|
|
1200
|
+
const window = this.#nudges.get(recipient) ?? [];
|
|
1201
|
+
window.push(Date.now());
|
|
1202
|
+
this.#nudges.set(recipient, window);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** True when a session row's last_seen is older than the staleness
|
|
1207
|
+
* threshold. Used by chat_list to mark ghosts. */
|
|
1208
|
+
export function isStale(row: ChatSessionRow, staleMinutes: number, now = Date.now()): boolean {
|
|
1209
|
+
return now - Date.parse(row.last_seen) > staleMinutes * 60_000;
|
|
1210
|
+
}
|