@jeffober/thatch 0.2.4 → 0.2.6
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 +56 -26
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +48 -15
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +18 -10
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +4 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +194 -23
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +56 -15
- package/docs/dev/features/prediction-engine.md +71 -6
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +161 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +89 -0
- package/docs/dev/mcp-parity.md +5 -5
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +12 -9
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +29 -13
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +5 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +37 -10
- package/docs/user/cross-session-chat.md +76 -26
- package/docs/user/prediction-engine.md +23 -1
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +9 -7
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +358 -43
- package/src/commands.ts +123 -18
- package/src/db.ts +74 -3
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +101 -9
- package/src/opencode/tui-plugin.ts +90 -0
- package/src/opencode/v2.ts +440 -66
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +164 -29
- package/src/runtime.ts +298 -15
- package/src/scoring-engine.ts +40 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +231 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +6 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +454 -76
- package/src/tools.ts +2 -0
- package/src/watchers.ts +263 -26
- package/docs/plans/intuition-drives.md +0 -147
- package/docs/plans/llm-alerts.md +0 -287
- package/docs/plans/thatch-actions.md +0 -179
- package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/chat.ts
CHANGED
|
@@ -210,10 +210,53 @@ export function resolveRegisteredPredecessor(
|
|
|
210
210
|
export type ChatHostKind = "opencode" | "mcp";
|
|
211
211
|
|
|
212
212
|
// Display names appear inside wake prompts and chat_list output, so they are
|
|
213
|
-
// capped tight.
|
|
214
|
-
//
|
|
213
|
+
// capped tight. MAX_BODY_LEN remains the per-ROW bound: every stored row
|
|
214
|
+
// (a split part, or a body within the part budget) stays under it, so no
|
|
215
|
+
// single inbox row can turn a wake nudge into a context bomb - the WHOLE
|
|
216
|
+
// body is bounded separately by CHAT_MAX_BODY_CHARS.
|
|
215
217
|
export const MAX_BODY_LEN = 10_000;
|
|
216
218
|
|
|
219
|
+
// Same-sender, same-recipient, same-body sends within this window are
|
|
220
|
+
// treated as one logical message: a harness that times out a chat_send that
|
|
221
|
+
// actually committed retries the call, and without this guard the retry
|
|
222
|
+
// lands a second identical row (observed 2026-10-07: two rows 8s apart, one
|
|
223
|
+
// delivery pass). The window is deliberately short and not env-tunable -
|
|
224
|
+
// two identical bodies this close together are a retry; outside it, they
|
|
225
|
+
// are two messages. Read state refines this: a duplicate is suppressed
|
|
226
|
+
// only while the earlier copy is still UNREAD (see #isRecentDuplicate).
|
|
227
|
+
export const CHAT_DEDUPE_WINDOW_MS = 10_000;
|
|
228
|
+
|
|
229
|
+
// Auto-split part budget. A body over the part budget is not refused; it
|
|
230
|
+
// travels as several messages. The DEFAULT is sized to the worst host we
|
|
231
|
+
// have observed: a version-skewed opencode install whose tool-result
|
|
232
|
+
// renderer clipped at exactly 2000 characters with no saved-output pointer,
|
|
233
|
+
// which cut each part off before the model saw it. Current opencode v2
|
|
234
|
+
// (pinned tag v2.0.24) passes 50 KiB + 2000 lines per tool result
|
|
235
|
+
// (packages/core/src/tool-output.ts MAX_LINES/MAX_BYTES, host-configurable
|
|
236
|
+
// via tool_output.max_lines/max_bytes), so hosts on a known-good version
|
|
237
|
+
// can safely raise THATCH_CHAT_SPLIT_PART_LEN.
|
|
238
|
+
//
|
|
239
|
+
// The markers: the FIRST part is prefixed "(message 1 of N)" so the reader
|
|
240
|
+
// knows more is coming and waits before acting, and every part but the last
|
|
241
|
+
// is suffixed "(continued in next message)" - no suffix means the batch is
|
|
242
|
+
// done.
|
|
243
|
+
function envChatInt(name: string, fallback: number, min: number, max: number): number {
|
|
244
|
+
const parsed = Number(process.env[name]);
|
|
245
|
+
if (!Number.isInteger(parsed)) return fallback;
|
|
246
|
+
return Math.min(max, Math.max(min, parsed));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const CHAT_SPLIT_PART_LEN = envChatInt("THATCH_CHAT_SPLIT_PART_LEN", 2000, 500, MAX_BODY_LEN - 1000);
|
|
250
|
+
const SPLIT_CONTINUED = "(continued in next message)";
|
|
251
|
+
|
|
252
|
+
// Acceptance ceiling on the WHOLE body, in characters, checked up front -
|
|
253
|
+
// parts derive from it (ceil(total/partLen)). The grouped chat_read of a
|
|
254
|
+
// split batch renders as ONE tool result, so the ceiling keeps a worst-case
|
|
255
|
+
// batch read (all parts plus framing) comfortably inside the 50 KiB v2
|
|
256
|
+
// envelope while still bounding the context bomb the old 10k refusal used
|
|
257
|
+
// to block. At the 2000-char default part size that is at most 10 parts.
|
|
258
|
+
export const CHAT_MAX_BODY_CHARS = envChatInt("THATCH_CHAT_MAX_BODY_CHARS", 20_000, 1_000, 1_000_000);
|
|
259
|
+
|
|
217
260
|
// Assigned names are lowercase slugs of a pool name with a numeric counter
|
|
218
261
|
// suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
|
|
219
262
|
// stays exported for the pool conformance test. Slugs never contain
|
|
@@ -263,6 +306,24 @@ export function isDefaultSessionTitle(title: string): boolean {
|
|
|
263
306
|
return /^New session - /.test(title) || /^Child session - /.test(title);
|
|
264
307
|
}
|
|
265
308
|
|
|
309
|
+
// The title of child sessions the plugin itself creates for direct
|
|
310
|
+
// extraction (triggerExtraction). They are machinery, not chat participants:
|
|
311
|
+
// they must never auto-register. The title is the one DURABLE,
|
|
312
|
+
// host-owned signal - in-memory child maps are lost to plugin reloads and
|
|
313
|
+
// daemon restarts, which is exactly how the September/October 2026
|
|
314
|
+
// thatch-extraction corpse rows entered the registry (81 rows by 2026-10-07).
|
|
315
|
+
export const EXTRACTION_CHILD_TITLE = "thatch-extraction";
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Whether a session title belongs to plugin machinery rather than a chat
|
|
319
|
+
* participant. Both auto-registration paths check this (the idle path has
|
|
320
|
+
* the live title in hand); explicit chat_register is unaffected - a model
|
|
321
|
+
* deliberately joining the directory is never machinery.
|
|
322
|
+
*/
|
|
323
|
+
export function isMachinerySessionTitle(title: string): boolean {
|
|
324
|
+
return title === EXTRACTION_CHILD_TITLE;
|
|
325
|
+
}
|
|
326
|
+
|
|
266
327
|
// Poller cadence. A hosting harness beats each of its sessions once per
|
|
267
328
|
// interval and delivers their mail on the same tick.
|
|
268
329
|
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
@@ -290,18 +351,28 @@ export function humanAge(iso: string, now = Date.now()): string {
|
|
|
290
351
|
return `${Math.floor(minutes / (24 * 60))}d ago`;
|
|
291
352
|
}
|
|
292
353
|
|
|
293
|
-
/** The liveness verdict for one roster row, from heartbeat age
|
|
294
|
-
*
|
|
295
|
-
* - "
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
|
|
354
|
+
/** The liveness verdict for one roster row, from heartbeat age plus (for
|
|
355
|
+
* opencode rows) the open-tab set:
|
|
356
|
+
* - "open" - fresh heartbeat AND a tab is open now (session presence)
|
|
357
|
+
* - "recent" - fresh heartbeat, no known tab: an instance that saw this
|
|
358
|
+
* session is alive and beating it, but the session itself may or may
|
|
359
|
+
* not be wakeable (a closed tab's wake can be swallowed; a daemon-
|
|
360
|
+
* resident subordinate runs headless and may still pick up mail)
|
|
361
|
+
* - "stale" - the hosting harness has missed two beats and is presumed
|
|
362
|
+
* gone
|
|
363
|
+
* - "active"/"idle" for MCP rows (turn-driven; idle between prompts is
|
|
364
|
+
* the NORMAL state, so an idle MCP row is reachable, not dead)
|
|
365
|
+
* Heartbeat age alone decides staleness - a process id cannot serve: the
|
|
366
|
+
* same session is re-hosted by a new process on every `-s` resume, so a
|
|
367
|
+
* pid stamped on the row lies as soon as a different harness beats it.
|
|
368
|
+
* The tab set is a READ-ONLY refinement (src/tab-state.ts) consumed at
|
|
369
|
+
* render/broadcast time; omitting it degrades every fresh opencode row
|
|
370
|
+
* to "recent" - honest, never a false "open". */
|
|
371
|
+
export function chatLiveness(row: ChatSessionRow, now = Date.now(), openTabs?: ReadonlySet<string>): "open" | "recent" | "stale" | "active" | "idle" {
|
|
302
372
|
const stale = isStale(row, now);
|
|
303
373
|
if (row.host_kind === "mcp") return stale ? "idle" : "active";
|
|
304
|
-
|
|
374
|
+
if (stale) return "stale";
|
|
375
|
+
return openTabs?.has(row.session_id) ? "open" : "recent";
|
|
305
376
|
}
|
|
306
377
|
|
|
307
378
|
/** Orders roster rows for display: project first (so a shared-tree reader
|
|
@@ -380,6 +451,98 @@ export function createWakeGate(deps: {
|
|
|
380
451
|
};
|
|
381
452
|
}
|
|
382
453
|
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Body auto-split (shared by send and broadcast)
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Splits an oversized body into deliverable parts. Bodies over the part
|
|
460
|
+
* budget travel as several messages instead of being refused, so a caller
|
|
461
|
+
* never has to split by hand. Cuts prefer blank-line paragraph boundaries
|
|
462
|
+
* (a cut at "\n\n" cannot split a line, a word, or a surrogate pair), falling back to
|
|
463
|
+
* a hard cut that backs off a trailing high surrogate so emoji-heavy text
|
|
464
|
+
* never splits mid-character. Every chunk is capped at CHAT_SPLIT_PART_LEN
|
|
465
|
+
* UTF-16 units - the same unit .length counts - which leaves marker headroom
|
|
466
|
+
* inside MAX_BODY_LEN after decoration. A body within the limit comes back
|
|
467
|
+
* as the single, unmodified part. Returns [] for an empty input.
|
|
468
|
+
*/
|
|
469
|
+
export function splitChatBody(body: string, partLen: number = CHAT_SPLIT_PART_LEN): string[] {
|
|
470
|
+
const text = body.trim();
|
|
471
|
+
if (text.length <= partLen) return text ? [text] : [];
|
|
472
|
+
const chunks: string[] = [];
|
|
473
|
+
let rest = text;
|
|
474
|
+
while (rest.length > partLen) {
|
|
475
|
+
const cut = nextSplitPoint(rest, partLen);
|
|
476
|
+
const part = rest.slice(0, cut).trim();
|
|
477
|
+
// The cut's own break characters (and any boundary whitespace) are
|
|
478
|
+
// consumed here; the next chunk starts at real content.
|
|
479
|
+
rest = rest.slice(cut).trim();
|
|
480
|
+
if (part) chunks.push(part);
|
|
481
|
+
}
|
|
482
|
+
if (rest) chunks.push(rest);
|
|
483
|
+
const total = chunks.length;
|
|
484
|
+
if (total <= 1) return chunks;
|
|
485
|
+
return chunks.map((chunk, i) => {
|
|
486
|
+
const withPrefix = i === 0 ? `(message 1 of ${total})\n\n${chunk}` : chunk;
|
|
487
|
+
return i < total - 1 ? `${withPrefix}\n\n${SPLIT_CONTINUED}` : withPrefix;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Read-side clip budget. The chat_read drain clips any single message
|
|
492
|
+
// longer than this and stamps a clip marker (which names CHARS - never a
|
|
493
|
+
// bare number) with a deterministic paging pointer; chat_read's message +
|
|
494
|
+
// offset arguments page through the rest. Sized to the same worst-host
|
|
495
|
+
// budget as the split part length.
|
|
496
|
+
export const CHAT_READ_PAGE_LEN = 2000;
|
|
497
|
+
|
|
498
|
+
/** Renders one message body for the drain: full when it fits one read
|
|
499
|
+
* page, else the first page plus a clip marker that names the unit
|
|
500
|
+
* (characters) and the deterministic recovery path. */
|
|
501
|
+
export function chatReadClip(body: string, id: number, page: number = CHAT_READ_PAGE_LEN): string {
|
|
502
|
+
if (body.length <= page) return body;
|
|
503
|
+
return `${body.slice(0, page)}\n[clipped: showing ${page} of ${body.length} characters - continue with chat_read, message ${id}, offset ${page}]`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** One page of a paged single-message read (chat_read's message/offset
|
|
507
|
+
* arguments). Deterministic recovery: names the character window and
|
|
508
|
+
* either the next offset or "final page". Never stamps read - only the
|
|
509
|
+
* drain marks mail read. */
|
|
510
|
+
export function chatReadPage(
|
|
511
|
+
body: string,
|
|
512
|
+
id: number,
|
|
513
|
+
offset: number,
|
|
514
|
+
page: number = CHAT_READ_PAGE_LEN,
|
|
515
|
+
): { ok: true; text: string } | { ok: false; error: string } {
|
|
516
|
+
if (offset < 0 || offset >= body.length) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
error: `Offset ${offset} is outside message ${id} (${body.length} characters) - use an offset from 0 to ${Math.max(0, body.length - 1)}.`,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const end = Math.min(body.length, offset + page);
|
|
523
|
+
const pos =
|
|
524
|
+
end < body.length
|
|
525
|
+
? `[showing characters ${offset}-${end} of ${body.length} - next page: chat_read, message ${id}, offset ${end}]`
|
|
526
|
+
: `[showing characters ${offset}-${end} of ${body.length} - final page]`;
|
|
527
|
+
return { ok: true, text: `${pos}\n${body.slice(offset, end)}` };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Where `text` (known to be longer than `partLen`) should be cut
|
|
531
|
+
* for its first chunk: the last blank-line boundary inside the part budget,
|
|
532
|
+
* else a surrogate-safe hard cut at the budget. */
|
|
533
|
+
function nextSplitPoint(text: string, partLen: number): number {
|
|
534
|
+
const paragraph = text.slice(0, partLen).lastIndexOf("\n\n");
|
|
535
|
+
if (paragraph > 0) return paragraph;
|
|
536
|
+
let cut = partLen;
|
|
537
|
+
// A hard cut between the two UTF-16 units of a surrogate pair would
|
|
538
|
+
// corrupt the character; back off one unit so the pair stays whole on
|
|
539
|
+
// the right side.
|
|
540
|
+
const high = text.charCodeAt(cut - 1);
|
|
541
|
+
const low = text.charCodeAt(cut);
|
|
542
|
+
if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) cut--;
|
|
543
|
+
return cut;
|
|
544
|
+
}
|
|
545
|
+
|
|
383
546
|
/**
|
|
384
547
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
385
548
|
* Database handle; the tables are created by ThatchDB's schema init.
|
|
@@ -447,7 +610,7 @@ export class ChatStore {
|
|
|
447
610
|
// is somehow taken, fall through to the counter draw.
|
|
448
611
|
const claim = this.#db
|
|
449
612
|
.query("SELECT name FROM chat_name_claims WHERE session_id = ?")
|
|
450
|
-
.get(sessionID) as { name: string } |
|
|
613
|
+
.get(sessionID) as { name: string } | null;
|
|
451
614
|
if (claim) {
|
|
452
615
|
const reclaimed = this.#insertSession(sessionID, claim.name, project, cleanTopic, kind, worktree);
|
|
453
616
|
if (reclaimed.ok) {
|
|
@@ -518,6 +681,24 @@ export class ChatStore {
|
|
|
518
681
|
this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
|
|
519
682
|
}
|
|
520
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Marks a plugin-created session as machinery (reason names the creator,
|
|
686
|
+
* e.g. "extraction"). Unlike the in-memory child maps, the marker survives
|
|
687
|
+
* plugin reloads and daemon restarts, so the auto-registration paths can
|
|
688
|
+
* refuse the session even when the maps were lost. Markers age out with
|
|
689
|
+
* the auto-row TTL in pruneStaleAuto - session ids are never reused, so an
|
|
690
|
+
* expired marker cannot resurrect a row for a live session.
|
|
691
|
+
*/
|
|
692
|
+
markMachinery(sessionID: string, reason: string): void {
|
|
693
|
+
this.#db.run("INSERT OR IGNORE INTO chat_machinery (session_id, reason) VALUES (?, ?)", [sessionID, reason]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Whether the session was marked machinery (see markMachinery). */
|
|
697
|
+
isMachinery(sessionID: string): boolean {
|
|
698
|
+
const row = this.#db.query("SELECT 1 FROM chat_machinery WHERE session_id = ?").get(sessionID);
|
|
699
|
+
return row !== null && row !== undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
521
702
|
/**
|
|
522
703
|
* Whether the leave tombstone for `sessionID` has been superseded: a
|
|
523
704
|
* newer registration exists in the same project (registered after the
|
|
@@ -593,6 +774,11 @@ export class ChatStore {
|
|
|
593
774
|
// session), but the sweep keeps the table from growing monotonically
|
|
594
775
|
// forever.
|
|
595
776
|
this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
|
|
777
|
+
// Machinery markers age out on the same TTL. They only need to outlive
|
|
778
|
+
// the marked session's possible events; ids are never reused, so an
|
|
779
|
+
// expired marker cannot let a row come back for a live session (the
|
|
780
|
+
// title guard in the registration paths covers the residue).
|
|
781
|
+
this.#db.run("DELETE FROM chat_machinery WHERE created_at < ?", [cutoff]);
|
|
596
782
|
return result.changes;
|
|
597
783
|
}
|
|
598
784
|
|
|
@@ -617,17 +803,20 @@ export class ChatStore {
|
|
|
617
803
|
* sender nudging itself). Returns the resolved recipient so callers never
|
|
618
804
|
* need a second lookup - a re-lookup could race a concurrent unregister
|
|
619
805
|
* and fail after the message already landed.
|
|
806
|
+
*
|
|
807
|
+
* An oversized body is auto-split into parts (splitChatBody) delivered as
|
|
808
|
+
* several rows in ONE transaction, so a crash cannot leave a partial
|
|
809
|
+
* batch in the reader's inbox. `parts` reports how many rows the send
|
|
810
|
+
* produced (or would have produced, when deduped), and the tool layer
|
|
811
|
+
* surfaces it so a caller cannot mistake a split for a single delivery.
|
|
620
812
|
*/
|
|
621
813
|
send(
|
|
622
814
|
fromSession: string,
|
|
623
815
|
toNameOrID: string,
|
|
624
816
|
body: string,
|
|
625
|
-
): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
|
|
817
|
+
): { ok: true; recipient: { session_id: string; name: string }; parts: number; deduped?: boolean } | { ok: false; error: string } {
|
|
626
818
|
const trimmed = body.trim();
|
|
627
|
-
if (!trimmed) return { ok: false, error:
|
|
628
|
-
if (trimmed.length > MAX_BODY_LEN) {
|
|
629
|
-
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
630
|
-
}
|
|
819
|
+
if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
|
|
631
820
|
const sender = this.#find(fromSession);
|
|
632
821
|
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
633
822
|
const recipient = this.find(toNameOrID);
|
|
@@ -637,55 +826,154 @@ export class ChatStore {
|
|
|
637
826
|
if (recipient.session_id === fromSession) {
|
|
638
827
|
return { ok: false, error: "You cannot message yourself." };
|
|
639
828
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
829
|
+
// Acceptance ceiling, checked before any split: the whole body is
|
|
830
|
+
// capped in characters so a runaway sender cannot flood the inbox with
|
|
831
|
+
// parts. The attempted count rides the error so a caller can split
|
|
832
|
+
// deterministically instead of bisecting.
|
|
833
|
+
if (trimmed.length > CHAT_MAX_BODY_CHARS) {
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const parts = splitChatBody(trimmed);
|
|
840
|
+
let deduped = false;
|
|
841
|
+
// One transaction: the whole batch lands or none of it - a crash
|
|
842
|
+
// mid-insert cannot deliver a partial message.
|
|
843
|
+
this.#db.transaction(() => {
|
|
844
|
+
// The batch's signature is its rows: the transactional insert makes
|
|
845
|
+
// the parts all-or-nothing, and the dedupe condition is READ STATE -
|
|
846
|
+
// any UNREAD part from a prior identical batch suppresses the retry
|
|
847
|
+
// (idempotent even while the recipient is mid-read), while a fully
|
|
848
|
+
// read batch lets an intentional repeat deliver. Checking parts
|
|
849
|
+
// rather than only the first also keeps two identical chunks inside
|
|
850
|
+
// one body from suppressing each other.
|
|
851
|
+
if (this.#isRecentDuplicate(fromSession, recipient.session_id, parts, 0)) {
|
|
852
|
+
deduped = true;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
for (const part of parts) {
|
|
856
|
+
this.#db.run(
|
|
857
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
|
|
858
|
+
[fromSession, recipient.session_id, part, nowIso()],
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
})();
|
|
862
|
+
return {
|
|
863
|
+
ok: true,
|
|
864
|
+
recipient: { session_id: recipient.session_id, name: recipient.name },
|
|
865
|
+
parts: parts.length,
|
|
866
|
+
...(deduped ? { deduped: true } : {}),
|
|
867
|
+
};
|
|
645
868
|
}
|
|
646
869
|
|
|
647
870
|
/**
|
|
648
|
-
* Posts a message to every other registered session at once.
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
871
|
+
* Posts a message to every other registered session at once. Opencode
|
|
872
|
+
* rows are skipped unless they are OPEN - a fresh heartbeat AND a tab
|
|
873
|
+
* open right now. Both skip classes are honest: a STALE row's host
|
|
874
|
+
* process has stopped heartbeat-ing and will never read the mail, and a
|
|
875
|
+
* RECENT row (fresh heartbeat, no open tab) is not reliably wakeable -
|
|
876
|
+
* the wake can be swallowed without a tab to receive it, so the fan-out
|
|
877
|
+
* would spend a model turn on a delivery that likely never runs. MCP
|
|
878
|
+
* rows always receive (they read mail at their next prompt). Every skip
|
|
879
|
+
* is listed by name in `skipped` so the caller can see who was left
|
|
880
|
+
* out and why. Each recipient gets its own inbox row per part, so the
|
|
881
|
+
* existing wake machinery (grouping, gating, rate cap) treats the
|
|
882
|
+
* broadcast as ordinary per-recipient mail. An oversized body is
|
|
883
|
+
* auto-split (splitChatBody) and every recipient gets every part inside
|
|
884
|
+
* the same single transaction; `parts` reports how many rows each
|
|
885
|
+
* recipient received.
|
|
654
886
|
*/
|
|
655
887
|
broadcast(
|
|
656
888
|
fromSession: string,
|
|
657
889
|
body: string,
|
|
658
|
-
|
|
890
|
+
openTabs?: ReadonlySet<string>,
|
|
891
|
+
): { ok: true; recipients: string[]; skipped: string[]; deduped: number; parts: number } | { ok: false; error: string } {
|
|
659
892
|
const trimmed = body.trim();
|
|
660
|
-
if (!trimmed) return { ok: false, error:
|
|
661
|
-
if (trimmed.length > MAX_BODY_LEN) {
|
|
662
|
-
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
663
|
-
}
|
|
893
|
+
if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
|
|
664
894
|
const sender = this.#find(fromSession);
|
|
665
895
|
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
896
|
+
// Same acceptance ceiling as send() - a broadcast fans out to every
|
|
897
|
+
// live session, so the cap matters even more here.
|
|
898
|
+
if (trimmed.length > CHAT_MAX_BODY_CHARS) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const parts = splitChatBody(trimmed);
|
|
905
|
+
// No provider = no tab signal: every fresh opencode row degrades to
|
|
906
|
+
// RECENT (honest - an unknown tab state never reads as open).
|
|
907
|
+
const open = openTabs ?? new Set<string>();
|
|
666
908
|
const recipients: string[] = [];
|
|
667
909
|
const skipped: string[] = [];
|
|
910
|
+
let deduped = 0;
|
|
668
911
|
// One transaction: a crash mid-loop rolls the whole fan-out back
|
|
669
912
|
// instead of leaving a partial broadcast where some recipients got the
|
|
670
913
|
// message and others silently did not.
|
|
671
914
|
this.#db.transaction(() => {
|
|
672
915
|
for (const row of this.list()) {
|
|
673
916
|
if (row.session_id === fromSession) continue;
|
|
674
|
-
//
|
|
675
|
-
//
|
|
917
|
+
// Two honest skip classes for opencode rows: STALE (host process
|
|
918
|
+
// gone - never reads the mail) and RECENT (fresh heartbeat, no
|
|
919
|
+
// open tab - the wake is not guaranteed to run a turn). An mcp
|
|
920
|
+
// row between turns reads its mail at the next prompt, so it
|
|
676
921
|
// always receives.
|
|
677
|
-
if (row.host_kind === "opencode" && isStale(row)) {
|
|
922
|
+
if (row.host_kind === "opencode" && (isStale(row) || !open.has(row.session_id))) {
|
|
678
923
|
skipped.push(row.name);
|
|
679
924
|
continue;
|
|
680
925
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
926
|
+
// A retried broadcast counts as delivered (the original batch
|
|
927
|
+
// stands) - the recipient list stays complete either way. Same
|
|
928
|
+
// read-state rule as send(): any unread part from the prior batch
|
|
929
|
+
// suppresses the retry for THIS recipient; a fully read batch
|
|
930
|
+
// delivers again.
|
|
931
|
+
if (this.#isRecentDuplicate(fromSession, row.session_id, parts, 1)) {
|
|
932
|
+
deduped++;
|
|
933
|
+
recipients.push(row.name);
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
for (const part of parts) {
|
|
937
|
+
this.#db.run(
|
|
938
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
|
|
939
|
+
[fromSession, row.session_id, part, nowIso()],
|
|
940
|
+
);
|
|
941
|
+
}
|
|
685
942
|
recipients.push(row.name);
|
|
686
943
|
}
|
|
687
944
|
})();
|
|
688
|
-
return { ok: true, recipients, skipped };
|
|
945
|
+
return { ok: true, recipients, skipped, deduped, parts: parts.length };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* True when an UNREAD row from a prior send still exists inside the
|
|
950
|
+
* dedupe window - the signature of a client-side retry of a send that
|
|
951
|
+
* actually committed. The condition is READ STATE, not just recency:
|
|
952
|
+
* identical content is a duplicate only while the recipient has not
|
|
953
|
+
* consumed it (the mail is there; a retry adds nothing). Once every
|
|
954
|
+
* matching row was read, the same body delivers again - presumptively an
|
|
955
|
+
* intentional resend after the first was consumed ("ok" said twice on
|
|
956
|
+
* purpose). For a split batch, `bodies` is every part and ANY unread
|
|
957
|
+
* part suppresses the retry: idempotent even while the recipient is
|
|
958
|
+
* mid-read (the poller keeps nudging the unread parts), and only a fully
|
|
959
|
+
* read batch lets an identical repeat through. Timestamps are
|
|
960
|
+
* second-resolution ISO (nowIso's format), so string comparison works.
|
|
961
|
+
*/
|
|
962
|
+
#isRecentDuplicate(fromSession: string, toSession: string, bodies: string[], viaBroadcast: 0 | 1): boolean {
|
|
963
|
+
const cutoff = new Date(Date.now() - CHAT_DEDUPE_WINDOW_MS).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
964
|
+
const marks = bodies.map(() => "?").join(",");
|
|
965
|
+
// bun:sqlite's .get() returns null (not undefined) when no row matches -
|
|
966
|
+
// a truthiness check, not an identity check, or every send dedupes.
|
|
967
|
+
return (
|
|
968
|
+
this.#db
|
|
969
|
+
.query(
|
|
970
|
+
`SELECT 1 AS hit FROM chat_messages
|
|
971
|
+
WHERE from_session = ? AND to_session = ? AND via_broadcast = ?
|
|
972
|
+
AND body IN (${marks}) AND read_at IS NULL AND created_at >= ?
|
|
973
|
+
LIMIT 1`,
|
|
974
|
+
)
|
|
975
|
+
.get(fromSession, toSession, viaBroadcast, ...bodies, cutoff) != null
|
|
976
|
+
);
|
|
689
977
|
}
|
|
690
978
|
|
|
691
979
|
/**
|
|
@@ -728,6 +1016,33 @@ export class ChatStore {
|
|
|
728
1016
|
return row.n;
|
|
729
1017
|
}
|
|
730
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* One message from the caller's mailbox by row id - the paging path
|
|
1021
|
+
* behind chat_read's message/offset arguments. Scoped to the caller:
|
|
1022
|
+
* a session cannot page another session's mail. Deliberately NO read
|
|
1023
|
+
* stamp and no unread filter - paging works on read or unread rows and
|
|
1024
|
+
* is side-effect-free; only the drain marks mail read.
|
|
1025
|
+
*/
|
|
1026
|
+
messageById(sessionID: string, id: number): ChatInboxItem | null {
|
|
1027
|
+
const r = this.#db
|
|
1028
|
+
.query(
|
|
1029
|
+
`SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
|
|
1030
|
+
FROM chat_messages m
|
|
1031
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
1032
|
+
WHERE m.id = ? AND m.to_session = ?`,
|
|
1033
|
+
)
|
|
1034
|
+
.get(id, sessionID) as any;
|
|
1035
|
+
return r
|
|
1036
|
+
? {
|
|
1037
|
+
id: r.id,
|
|
1038
|
+
from_session: r.from_session,
|
|
1039
|
+
from_name: r.from_name ?? null,
|
|
1040
|
+
body: r.body,
|
|
1041
|
+
created_at: r.created_at,
|
|
1042
|
+
}
|
|
1043
|
+
: null;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
731
1046
|
/**
|
|
732
1047
|
* Records (or refreshes) the hook-parent-process -> session mapping: the
|
|
733
1048
|
* identity anchor that closes the caller-claimed `as` hole on Claude Code.
|
|
@@ -762,7 +1077,7 @@ export class ChatStore {
|
|
|
762
1077
|
JOIN chat_sessions s ON s.session_id = p.session_id
|
|
763
1078
|
WHERE p.ppid = ? AND p.seen_at >= ?`,
|
|
764
1079
|
)
|
|
765
|
-
.get(ppid, cutoff) as { session_id: string } |
|
|
1080
|
+
.get(ppid, cutoff) as { session_id: string } | null;
|
|
766
1081
|
return row?.session_id ?? null;
|
|
767
1082
|
}
|
|
768
1083
|
|