@jeffober/thatch 0.2.4 → 0.2.5
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 +8 -1
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/bin/thatch +27 -3
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +7 -2
- package/docs/dev/features/cli.md +2 -0
- package/docs/dev/features/cross-session-chat.md +118 -11
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/opencode-plugin.md +18 -3
- package/docs/dev/features/prediction-engine.md +44 -5
- package/docs/dev/features/session-tabs.md +155 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +68 -0
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +1 -1
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +12 -3
- package/docs/user/cli.md +3 -0
- package/docs/user/commands.md +11 -4
- package/docs/user/cross-session-chat.md +31 -8
- package/docs/user/prediction-engine.md +11 -0
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +1 -1
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/chat.ts +320 -27
- package/src/commands.ts +14 -0
- package/src/db.ts +51 -1
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +95 -7
- package/src/opencode/tui-plugin.ts +70 -0
- package/src/opencode/v2.ts +197 -4
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +110 -23
- package/src/runtime.ts +187 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +180 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +412 -55
- 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;
|
|
@@ -380,6 +441,98 @@ export function createWakeGate(deps: {
|
|
|
380
441
|
};
|
|
381
442
|
}
|
|
382
443
|
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Body auto-split (shared by send and broadcast)
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Splits an oversized body into deliverable parts. Bodies over the part
|
|
450
|
+
* budget travel as several messages instead of being refused, so a caller
|
|
451
|
+
* never has to split by hand. Cuts prefer blank-line paragraph boundaries
|
|
452
|
+
* (a cut at "\n\n" cannot split a line, a word, or a surrogate pair), falling back to
|
|
453
|
+
* a hard cut that backs off a trailing high surrogate so emoji-heavy text
|
|
454
|
+
* never splits mid-character. Every chunk is capped at CHAT_SPLIT_PART_LEN
|
|
455
|
+
* UTF-16 units - the same unit .length counts - which leaves marker headroom
|
|
456
|
+
* inside MAX_BODY_LEN after decoration. A body within the limit comes back
|
|
457
|
+
* as the single, unmodified part. Returns [] for an empty input.
|
|
458
|
+
*/
|
|
459
|
+
export function splitChatBody(body: string, partLen: number = CHAT_SPLIT_PART_LEN): string[] {
|
|
460
|
+
const text = body.trim();
|
|
461
|
+
if (text.length <= partLen) return text ? [text] : [];
|
|
462
|
+
const chunks: string[] = [];
|
|
463
|
+
let rest = text;
|
|
464
|
+
while (rest.length > partLen) {
|
|
465
|
+
const cut = nextSplitPoint(rest, partLen);
|
|
466
|
+
const part = rest.slice(0, cut).trim();
|
|
467
|
+
// The cut's own break characters (and any boundary whitespace) are
|
|
468
|
+
// consumed here; the next chunk starts at real content.
|
|
469
|
+
rest = rest.slice(cut).trim();
|
|
470
|
+
if (part) chunks.push(part);
|
|
471
|
+
}
|
|
472
|
+
if (rest) chunks.push(rest);
|
|
473
|
+
const total = chunks.length;
|
|
474
|
+
if (total <= 1) return chunks;
|
|
475
|
+
return chunks.map((chunk, i) => {
|
|
476
|
+
const withPrefix = i === 0 ? `(message 1 of ${total})\n\n${chunk}` : chunk;
|
|
477
|
+
return i < total - 1 ? `${withPrefix}\n\n${SPLIT_CONTINUED}` : withPrefix;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Read-side clip budget. The chat_read drain clips any single message
|
|
482
|
+
// longer than this and stamps a clip marker (which names CHARS - never a
|
|
483
|
+
// bare number) with a deterministic paging pointer; chat_read's message +
|
|
484
|
+
// offset arguments page through the rest. Sized to the same worst-host
|
|
485
|
+
// budget as the split part length.
|
|
486
|
+
export const CHAT_READ_PAGE_LEN = 2000;
|
|
487
|
+
|
|
488
|
+
/** Renders one message body for the drain: full when it fits one read
|
|
489
|
+
* page, else the first page plus a clip marker that names the unit
|
|
490
|
+
* (characters) and the deterministic recovery path. */
|
|
491
|
+
export function chatReadClip(body: string, id: number, page: number = CHAT_READ_PAGE_LEN): string {
|
|
492
|
+
if (body.length <= page) return body;
|
|
493
|
+
return `${body.slice(0, page)}\n[clipped: showing ${page} of ${body.length} characters - continue with chat_read, message ${id}, offset ${page}]`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** One page of a paged single-message read (chat_read's message/offset
|
|
497
|
+
* arguments). Deterministic recovery: names the character window and
|
|
498
|
+
* either the next offset or "final page". Never stamps read - only the
|
|
499
|
+
* drain marks mail read. */
|
|
500
|
+
export function chatReadPage(
|
|
501
|
+
body: string,
|
|
502
|
+
id: number,
|
|
503
|
+
offset: number,
|
|
504
|
+
page: number = CHAT_READ_PAGE_LEN,
|
|
505
|
+
): { ok: true; text: string } | { ok: false; error: string } {
|
|
506
|
+
if (offset < 0 || offset >= body.length) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
error: `Offset ${offset} is outside message ${id} (${body.length} characters) - use an offset from 0 to ${Math.max(0, body.length - 1)}.`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const end = Math.min(body.length, offset + page);
|
|
513
|
+
const pos =
|
|
514
|
+
end < body.length
|
|
515
|
+
? `[showing characters ${offset}-${end} of ${body.length} - next page: chat_read, message ${id}, offset ${end}]`
|
|
516
|
+
: `[showing characters ${offset}-${end} of ${body.length} - final page]`;
|
|
517
|
+
return { ok: true, text: `${pos}\n${body.slice(offset, end)}` };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Where `text` (known to be longer than `partLen`) should be cut
|
|
521
|
+
* for its first chunk: the last blank-line boundary inside the part budget,
|
|
522
|
+
* else a surrogate-safe hard cut at the budget. */
|
|
523
|
+
function nextSplitPoint(text: string, partLen: number): number {
|
|
524
|
+
const paragraph = text.slice(0, partLen).lastIndexOf("\n\n");
|
|
525
|
+
if (paragraph > 0) return paragraph;
|
|
526
|
+
let cut = partLen;
|
|
527
|
+
// A hard cut between the two UTF-16 units of a surrogate pair would
|
|
528
|
+
// corrupt the character; back off one unit so the pair stays whole on
|
|
529
|
+
// the right side.
|
|
530
|
+
const high = text.charCodeAt(cut - 1);
|
|
531
|
+
const low = text.charCodeAt(cut);
|
|
532
|
+
if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) cut--;
|
|
533
|
+
return cut;
|
|
534
|
+
}
|
|
535
|
+
|
|
383
536
|
/**
|
|
384
537
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
385
538
|
* Database handle; the tables are created by ThatchDB's schema init.
|
|
@@ -447,7 +600,7 @@ export class ChatStore {
|
|
|
447
600
|
// is somehow taken, fall through to the counter draw.
|
|
448
601
|
const claim = this.#db
|
|
449
602
|
.query("SELECT name FROM chat_name_claims WHERE session_id = ?")
|
|
450
|
-
.get(sessionID) as { name: string } |
|
|
603
|
+
.get(sessionID) as { name: string } | null;
|
|
451
604
|
if (claim) {
|
|
452
605
|
const reclaimed = this.#insertSession(sessionID, claim.name, project, cleanTopic, kind, worktree);
|
|
453
606
|
if (reclaimed.ok) {
|
|
@@ -518,6 +671,24 @@ export class ChatStore {
|
|
|
518
671
|
this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
|
|
519
672
|
}
|
|
520
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Marks a plugin-created session as machinery (reason names the creator,
|
|
676
|
+
* e.g. "extraction"). Unlike the in-memory child maps, the marker survives
|
|
677
|
+
* plugin reloads and daemon restarts, so the auto-registration paths can
|
|
678
|
+
* refuse the session even when the maps were lost. Markers age out with
|
|
679
|
+
* the auto-row TTL in pruneStaleAuto - session ids are never reused, so an
|
|
680
|
+
* expired marker cannot resurrect a row for a live session.
|
|
681
|
+
*/
|
|
682
|
+
markMachinery(sessionID: string, reason: string): void {
|
|
683
|
+
this.#db.run("INSERT OR IGNORE INTO chat_machinery (session_id, reason) VALUES (?, ?)", [sessionID, reason]);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Whether the session was marked machinery (see markMachinery). */
|
|
687
|
+
isMachinery(sessionID: string): boolean {
|
|
688
|
+
const row = this.#db.query("SELECT 1 FROM chat_machinery WHERE session_id = ?").get(sessionID);
|
|
689
|
+
return row !== null && row !== undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
521
692
|
/**
|
|
522
693
|
* Whether the leave tombstone for `sessionID` has been superseded: a
|
|
523
694
|
* newer registration exists in the same project (registered after the
|
|
@@ -593,6 +764,11 @@ export class ChatStore {
|
|
|
593
764
|
// session), but the sweep keeps the table from growing monotonically
|
|
594
765
|
// forever.
|
|
595
766
|
this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
|
|
767
|
+
// Machinery markers age out on the same TTL. They only need to outlive
|
|
768
|
+
// the marked session's possible events; ids are never reused, so an
|
|
769
|
+
// expired marker cannot let a row come back for a live session (the
|
|
770
|
+
// title guard in the registration paths covers the residue).
|
|
771
|
+
this.#db.run("DELETE FROM chat_machinery WHERE created_at < ?", [cutoff]);
|
|
596
772
|
return result.changes;
|
|
597
773
|
}
|
|
598
774
|
|
|
@@ -617,17 +793,20 @@ export class ChatStore {
|
|
|
617
793
|
* sender nudging itself). Returns the resolved recipient so callers never
|
|
618
794
|
* need a second lookup - a re-lookup could race a concurrent unregister
|
|
619
795
|
* and fail after the message already landed.
|
|
796
|
+
*
|
|
797
|
+
* An oversized body is auto-split into parts (splitChatBody) delivered as
|
|
798
|
+
* several rows in ONE transaction, so a crash cannot leave a partial
|
|
799
|
+
* batch in the reader's inbox. `parts` reports how many rows the send
|
|
800
|
+
* produced (or would have produced, when deduped), and the tool layer
|
|
801
|
+
* surfaces it so a caller cannot mistake a split for a single delivery.
|
|
620
802
|
*/
|
|
621
803
|
send(
|
|
622
804
|
fromSession: string,
|
|
623
805
|
toNameOrID: string,
|
|
624
806
|
body: string,
|
|
625
|
-
): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
|
|
807
|
+
): { ok: true; recipient: { session_id: string; name: string }; parts: number; deduped?: boolean } | { ok: false; error: string } {
|
|
626
808
|
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
|
-
}
|
|
809
|
+
if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
|
|
631
810
|
const sender = this.#find(fromSession);
|
|
632
811
|
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
633
812
|
const recipient = this.find(toNameOrID);
|
|
@@ -637,34 +816,78 @@ export class ChatStore {
|
|
|
637
816
|
if (recipient.session_id === fromSession) {
|
|
638
817
|
return { ok: false, error: "You cannot message yourself." };
|
|
639
818
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
819
|
+
// Acceptance ceiling, checked before any split: the whole body is
|
|
820
|
+
// capped in characters so a runaway sender cannot flood the inbox with
|
|
821
|
+
// parts. The attempted count rides the error so a caller can split
|
|
822
|
+
// deterministically instead of bisecting.
|
|
823
|
+
if (trimmed.length > CHAT_MAX_BODY_CHARS) {
|
|
824
|
+
return {
|
|
825
|
+
ok: false,
|
|
826
|
+
error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const parts = splitChatBody(trimmed);
|
|
830
|
+
let deduped = false;
|
|
831
|
+
// One transaction: the whole batch lands or none of it - a crash
|
|
832
|
+
// mid-insert cannot deliver a partial message.
|
|
833
|
+
this.#db.transaction(() => {
|
|
834
|
+
// The batch's signature is its rows: the transactional insert makes
|
|
835
|
+
// the parts all-or-nothing, and the dedupe condition is READ STATE -
|
|
836
|
+
// any UNREAD part from a prior identical batch suppresses the retry
|
|
837
|
+
// (idempotent even while the recipient is mid-read), while a fully
|
|
838
|
+
// read batch lets an intentional repeat deliver. Checking parts
|
|
839
|
+
// rather than only the first also keeps two identical chunks inside
|
|
840
|
+
// one body from suppressing each other.
|
|
841
|
+
if (this.#isRecentDuplicate(fromSession, recipient.session_id, parts, 0)) {
|
|
842
|
+
deduped = true;
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
for (const part of parts) {
|
|
846
|
+
this.#db.run(
|
|
847
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
|
|
848
|
+
[fromSession, recipient.session_id, part, nowIso()],
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
})();
|
|
852
|
+
return {
|
|
853
|
+
ok: true,
|
|
854
|
+
recipient: { session_id: recipient.session_id, name: recipient.name },
|
|
855
|
+
parts: parts.length,
|
|
856
|
+
...(deduped ? { deduped: true } : {}),
|
|
857
|
+
};
|
|
645
858
|
}
|
|
646
859
|
|
|
647
860
|
/**
|
|
648
861
|
* Posts a message to every other registered session at once. Stale
|
|
649
862
|
* sessions are skipped, not messaged - a host process that has stopped
|
|
650
863
|
* heartbeat-ing will never read the mail, and a broadcast is for reaching
|
|
651
|
-
* live agents. Each recipient gets its own inbox row, so the
|
|
652
|
-
* wake machinery (grouping, gating, rate cap) treats the
|
|
653
|
-
* ordinary per-recipient mail.
|
|
864
|
+
* live agents. Each recipient gets its own inbox row per part, so the
|
|
865
|
+
* existing wake machinery (grouping, gating, rate cap) treats the
|
|
866
|
+
* broadcast as ordinary per-recipient mail. An oversized body is
|
|
867
|
+
* auto-split (splitChatBody) and every recipient gets every part inside
|
|
868
|
+
* the same single transaction; `parts` reports how many rows each
|
|
869
|
+
* recipient received.
|
|
654
870
|
*/
|
|
655
871
|
broadcast(
|
|
656
872
|
fromSession: string,
|
|
657
873
|
body: string,
|
|
658
|
-
): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
|
|
874
|
+
): { ok: true; recipients: string[]; skipped: string[]; deduped: number; parts: number } | { ok: false; error: string } {
|
|
659
875
|
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
|
-
}
|
|
876
|
+
if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
|
|
664
877
|
const sender = this.#find(fromSession);
|
|
665
878
|
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
879
|
+
// Same acceptance ceiling as send() - a broadcast fans out to every
|
|
880
|
+
// live session, so the cap matters even more here.
|
|
881
|
+
if (trimmed.length > CHAT_MAX_BODY_CHARS) {
|
|
882
|
+
return {
|
|
883
|
+
ok: false,
|
|
884
|
+
error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
const parts = splitChatBody(trimmed);
|
|
666
888
|
const recipients: string[] = [];
|
|
667
889
|
const skipped: string[] = [];
|
|
890
|
+
let deduped = 0;
|
|
668
891
|
// One transaction: a crash mid-loop rolls the whole fan-out back
|
|
669
892
|
// instead of leaving a partial broadcast where some recipients got the
|
|
670
893
|
// message and others silently did not.
|
|
@@ -678,14 +901,57 @@ export class ChatStore {
|
|
|
678
901
|
skipped.push(row.name);
|
|
679
902
|
continue;
|
|
680
903
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
904
|
+
// A retried broadcast counts as delivered (the original batch
|
|
905
|
+
// stands) - the recipient list stays complete either way. Same
|
|
906
|
+
// read-state rule as send(): any unread part from the prior batch
|
|
907
|
+
// suppresses the retry for THIS recipient; a fully read batch
|
|
908
|
+
// delivers again.
|
|
909
|
+
if (this.#isRecentDuplicate(fromSession, row.session_id, parts, 1)) {
|
|
910
|
+
deduped++;
|
|
911
|
+
recipients.push(row.name);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
for (const part of parts) {
|
|
915
|
+
this.#db.run(
|
|
916
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
|
|
917
|
+
[fromSession, row.session_id, part, nowIso()],
|
|
918
|
+
);
|
|
919
|
+
}
|
|
685
920
|
recipients.push(row.name);
|
|
686
921
|
}
|
|
687
922
|
})();
|
|
688
|
-
return { ok: true, recipients, skipped };
|
|
923
|
+
return { ok: true, recipients, skipped, deduped, parts: parts.length };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* True when an UNREAD row from a prior send still exists inside the
|
|
928
|
+
* dedupe window - the signature of a client-side retry of a send that
|
|
929
|
+
* actually committed. The condition is READ STATE, not just recency:
|
|
930
|
+
* identical content is a duplicate only while the recipient has not
|
|
931
|
+
* consumed it (the mail is there; a retry adds nothing). Once every
|
|
932
|
+
* matching row was read, the same body delivers again - presumptively an
|
|
933
|
+
* intentional resend after the first was consumed ("ok" said twice on
|
|
934
|
+
* purpose). For a split batch, `bodies` is every part and ANY unread
|
|
935
|
+
* part suppresses the retry: idempotent even while the recipient is
|
|
936
|
+
* mid-read (the poller keeps nudging the unread parts), and only a fully
|
|
937
|
+
* read batch lets an identical repeat through. Timestamps are
|
|
938
|
+
* second-resolution ISO (nowIso's format), so string comparison works.
|
|
939
|
+
*/
|
|
940
|
+
#isRecentDuplicate(fromSession: string, toSession: string, bodies: string[], viaBroadcast: 0 | 1): boolean {
|
|
941
|
+
const cutoff = new Date(Date.now() - CHAT_DEDUPE_WINDOW_MS).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
942
|
+
const marks = bodies.map(() => "?").join(",");
|
|
943
|
+
// bun:sqlite's .get() returns null (not undefined) when no row matches -
|
|
944
|
+
// a truthiness check, not an identity check, or every send dedupes.
|
|
945
|
+
return (
|
|
946
|
+
this.#db
|
|
947
|
+
.query(
|
|
948
|
+
`SELECT 1 AS hit FROM chat_messages
|
|
949
|
+
WHERE from_session = ? AND to_session = ? AND via_broadcast = ?
|
|
950
|
+
AND body IN (${marks}) AND read_at IS NULL AND created_at >= ?
|
|
951
|
+
LIMIT 1`,
|
|
952
|
+
)
|
|
953
|
+
.get(fromSession, toSession, viaBroadcast, ...bodies, cutoff) != null
|
|
954
|
+
);
|
|
689
955
|
}
|
|
690
956
|
|
|
691
957
|
/**
|
|
@@ -728,6 +994,33 @@ export class ChatStore {
|
|
|
728
994
|
return row.n;
|
|
729
995
|
}
|
|
730
996
|
|
|
997
|
+
/**
|
|
998
|
+
* One message from the caller's mailbox by row id - the paging path
|
|
999
|
+
* behind chat_read's message/offset arguments. Scoped to the caller:
|
|
1000
|
+
* a session cannot page another session's mail. Deliberately NO read
|
|
1001
|
+
* stamp and no unread filter - paging works on read or unread rows and
|
|
1002
|
+
* is side-effect-free; only the drain marks mail read.
|
|
1003
|
+
*/
|
|
1004
|
+
messageById(sessionID: string, id: number): ChatInboxItem | null {
|
|
1005
|
+
const r = this.#db
|
|
1006
|
+
.query(
|
|
1007
|
+
`SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
|
|
1008
|
+
FROM chat_messages m
|
|
1009
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
1010
|
+
WHERE m.id = ? AND m.to_session = ?`,
|
|
1011
|
+
)
|
|
1012
|
+
.get(id, sessionID) as any;
|
|
1013
|
+
return r
|
|
1014
|
+
? {
|
|
1015
|
+
id: r.id,
|
|
1016
|
+
from_session: r.from_session,
|
|
1017
|
+
from_name: r.from_name ?? null,
|
|
1018
|
+
body: r.body,
|
|
1019
|
+
created_at: r.created_at,
|
|
1020
|
+
}
|
|
1021
|
+
: null;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
731
1024
|
/**
|
|
732
1025
|
* Records (or refreshes) the hook-parent-process -> session mapping: the
|
|
733
1026
|
* identity anchor that closes the caller-claimed `as` hole on Claude Code.
|
|
@@ -762,7 +1055,7 @@ export class ChatStore {
|
|
|
762
1055
|
JOIN chat_sessions s ON s.session_id = p.session_id
|
|
763
1056
|
WHERE p.ppid = ? AND p.seen_at >= ?`,
|
|
764
1057
|
)
|
|
765
|
-
.get(ppid, cutoff) as { session_id: string } |
|
|
1058
|
+
.get(ppid, cutoff) as { session_id: string } | null;
|
|
766
1059
|
return row?.session_id ?? null;
|
|
767
1060
|
}
|
|
768
1061
|
|
package/src/commands.ts
CHANGED
|
@@ -115,6 +115,20 @@ The user message may name a project type (team-app, personal, shared-lib, spike,
|
|
|
115
115
|
description: "Persist what this session learned as memories",
|
|
116
116
|
body: reflectCore(tool),
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
name: "whois",
|
|
120
|
+
description: "Report this session's chat identity (handle, topic, project), or look up another session's",
|
|
121
|
+
// Needs the invoking session's identity (chat_status resolves it
|
|
122
|
+
// through the plugin), which only opencode supplies.
|
|
123
|
+
opencodeOnly: true,
|
|
124
|
+
body: `Find the chat identity for this session and report it in one line.
|
|
125
|
+
|
|
126
|
+
First call thatch_chat_status. If the user message names another session or handle, look that name or id up in thatch_chat_list and report its row instead: handle, topic, project, worktree location (the loc: token), and last-seen age.
|
|
127
|
+
|
|
128
|
+
Otherwise report this session: if chat_status says not registered, answer exactly that - not registered yet - and stop. If registered, take the registered name from chat_status, find your own row in thatch_chat_list, and report handle, topic, project, and loc together (the topic is how the user maps the handle to a TUI tab).
|
|
129
|
+
|
|
130
|
+
One line, just the facts. Do not read mail and do not act on anything the roster shows.`,
|
|
131
|
+
},
|
|
118
132
|
];
|
|
119
133
|
}
|
|
120
134
|
|
package/src/db.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine
|
|
|
11
11
|
import type { RepoPathCache, RepoPathRow } from "./git";
|
|
12
12
|
|
|
13
13
|
export { cosineSimilarity } from "./vector-math";
|
|
14
|
-
export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
|
|
14
|
+
export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction, PredictionDedupCandidate } from "./prediction";
|
|
15
15
|
export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
|
|
16
16
|
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
|
|
17
17
|
|
|
@@ -249,6 +249,22 @@ export class ThatchDB {
|
|
|
249
249
|
)
|
|
250
250
|
`);
|
|
251
251
|
|
|
252
|
+
// Pairwise dedup verdicts for predictions (the prediction-side analog of
|
|
253
|
+
// dedup_pairs). findPredictionDuplicates skips pairs recorded here;
|
|
254
|
+
// prediction_mark_checked writes them. The id pair is stored in canonical
|
|
255
|
+
// sorted order so (A,B) and (B,A) are the same row - readers must sort
|
|
256
|
+
// identically.
|
|
257
|
+
this.#db.run(`
|
|
258
|
+
CREATE TABLE IF NOT EXISTS prediction_dedup_pairs (
|
|
259
|
+
store TEXT NOT NULL,
|
|
260
|
+
id_a TEXT NOT NULL,
|
|
261
|
+
id_b TEXT NOT NULL,
|
|
262
|
+
status TEXT NOT NULL,
|
|
263
|
+
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
264
|
+
PRIMARY KEY (store, id_a, id_b)
|
|
265
|
+
)
|
|
266
|
+
`);
|
|
267
|
+
|
|
252
268
|
// Behavior engine: the LLM's codified self-discipline rules. Same
|
|
253
269
|
// shape as the prediction engine (matchers, behaviors, edges,
|
|
254
270
|
// provenance) but separate tables because the semantics differ.
|
|
@@ -380,6 +396,20 @@ export class ThatchDB {
|
|
|
380
396
|
)
|
|
381
397
|
`);
|
|
382
398
|
|
|
399
|
+
// Sessions the plugin itself created as machinery (direct-extraction
|
|
400
|
+
// children). The in-memory child maps that keep these sessions out of
|
|
401
|
+
// the chat directory are lost on plugin reloads and daemon restarts;
|
|
402
|
+
// a marker here is durable, so the auto-registration paths refuse the
|
|
403
|
+
// session even when the maps were lost. Markers age out with the
|
|
404
|
+
// auto-row TTL (session ids are never reused).
|
|
405
|
+
this.#db.run(`
|
|
406
|
+
CREATE TABLE IF NOT EXISTS chat_machinery (
|
|
407
|
+
session_id TEXT PRIMARY KEY,
|
|
408
|
+
reason TEXT NOT NULL,
|
|
409
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
410
|
+
)
|
|
411
|
+
`);
|
|
412
|
+
|
|
383
413
|
// Hook-parent-process -> session mapping (Claude Code identity anchor):
|
|
384
414
|
// hooks learn the true session id from the host's hook payload and run
|
|
385
415
|
// as children of the same process that spawned the MCP server, so the
|
|
@@ -907,6 +937,14 @@ export class ThatchDB {
|
|
|
907
937
|
return this.#predictions.scorePredictionNudge(stores, embedding, threshold, limit);
|
|
908
938
|
}
|
|
909
939
|
|
|
940
|
+
findPredictionDuplicates(store: string, threshold = 0.70) {
|
|
941
|
+
return this.#predictions.findPredictionDuplicates(store, threshold);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
markPredictionPairChecked(store: string, idA: string, idB: string, status: string): void {
|
|
945
|
+
this.#predictions.markPairChecked(store, idA, idB, status);
|
|
946
|
+
}
|
|
947
|
+
|
|
910
948
|
findNearestMatcher(store: string, embedding: Float32Array, threshold: number) {
|
|
911
949
|
return this.#predictions.findNearestMatcher(store, embedding, threshold);
|
|
912
950
|
}
|
|
@@ -1035,6 +1073,14 @@ export class ThatchDB {
|
|
|
1035
1073
|
return this.#chat.clearLeaveTombstone(sessionID);
|
|
1036
1074
|
}
|
|
1037
1075
|
|
|
1076
|
+
markChatMachinery(sessionID: string, reason: string) {
|
|
1077
|
+
return this.#chat.markMachinery(sessionID, reason);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
isChatMachinery(sessionID: string) {
|
|
1081
|
+
return this.#chat.isMachinery(sessionID);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1038
1084
|
chatLeaveSuperseded(sessionID: string, project: string | null) {
|
|
1039
1085
|
return this.#chat.leaveSuperseded(sessionID, project);
|
|
1040
1086
|
}
|
|
@@ -1120,6 +1166,10 @@ export class ThatchDB {
|
|
|
1120
1166
|
return this.#chat.read(sessionID);
|
|
1121
1167
|
}
|
|
1122
1168
|
|
|
1169
|
+
chatMessageById(sessionID: string, id: number) {
|
|
1170
|
+
return this.#chat.messageById(sessionID, id);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1123
1173
|
unreadChatCount(sessionID: string) {
|
|
1124
1174
|
return this.#chat.unreadCount(sessionID);
|
|
1125
1175
|
}
|
package/src/git.ts
CHANGED
|
@@ -102,7 +102,9 @@ function canonicalWorktreeKeys(dir: string): string[] {
|
|
|
102
102
|
return [...new Set(keys)];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
/** Existence check shared by git helpers and tool arg validation (the
|
|
106
|
+
* session-tab tool validates its directory/worktree args with it). */
|
|
107
|
+
export function pathExists(dir: string): boolean {
|
|
106
108
|
try {
|
|
107
109
|
statSync(dir);
|
|
108
110
|
return true;
|
package/src/hygiene.ts
CHANGED
|
@@ -24,6 +24,11 @@ export async function hygieneReport(
|
|
|
24
24
|
parts.push(`${dupes} duplicate-candidate pair${dupes === 1 ? "" : "s"} pending review`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const predDupes = db.findPredictionDuplicates(repo).length;
|
|
28
|
+
if (predDupes > 0) {
|
|
29
|
+
parts.push(`${predDupes} prediction duplicate pair${predDupes === 1 ? "" : "s"} pending review`);
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
const cutoff = new Date(Date.now() - STALE_DAYS * 86_400_000).toISOString();
|
|
28
33
|
const stale = db.staleEntryCount(repo, cutoff);
|
|
29
34
|
if (stale > 0) {
|