@jeffober/thatch 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +10 -3
  2. package/artifacts/skills/common.md +4 -0
  3. package/artifacts/skills/thatch-code-review.md +10 -4
  4. package/artifacts/skills/thatch-review-synthesizer.md +57 -6
  5. package/artifacts/skills/thatch-review-technique.md +173 -0
  6. package/bin/thatch +160 -16
  7. package/docs/dev/README.md +26 -14
  8. package/docs/dev/features/README.md +1 -0
  9. package/docs/dev/features/behavior-engine.md +5 -3
  10. package/docs/dev/features/cli.md +22 -4
  11. package/docs/dev/features/cross-session-chat.md +256 -0
  12. package/docs/dev/features/database.md +52 -4
  13. package/docs/dev/features/memory-store.md +13 -0
  14. package/docs/dev/features/multi-host.md +17 -9
  15. package/docs/dev/features/prediction-engine.md +6 -3
  16. package/docs/dev/features/setup.md +12 -5
  17. package/docs/dev/gotchas.md +7 -4
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +5 -4
  20. package/docs/dev/skills.md +7 -3
  21. package/docs/user/README.md +24 -4
  22. package/docs/user/cli.md +30 -0
  23. package/docs/user/code-review.md +4 -3
  24. package/docs/user/cross-session-chat.md +102 -0
  25. package/docs/user/memory.md +3 -0
  26. package/docs/user/setup.md +20 -4
  27. package/docs/user/skills.md +7 -5
  28. package/package.json +2 -1
  29. package/src/behavior.ts +1 -1
  30. package/src/chat-names.ts +121 -0
  31. package/src/chat.ts +879 -0
  32. package/src/db.ts +181 -2
  33. package/src/embeddings.ts +152 -9
  34. package/src/index.ts +113 -5
  35. package/src/prediction.ts +1 -1
  36. package/src/prompts.ts +133 -8
  37. package/src/scoring-engine.ts +25 -4
  38. package/src/setup.ts +83 -29
  39. package/src/skills.ts +47 -16
  40. package/src/tool-defs.ts +422 -40
package/src/db.ts CHANGED
@@ -4,11 +4,13 @@ import {
4
4
  PredictionEngine,
5
5
  } from "./prediction";
6
6
  import { BehaviorEngine } from "./behavior";
7
+ import { ChatStore, type ChatHostKind } from "./chat";
7
8
  import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
8
9
 
9
10
  export { cosineSimilarity } from "./vector-math";
10
11
  export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
11
12
  export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
13
+ export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatResult } from "./chat";
12
14
 
13
15
  export interface MemoryRow {
14
16
  slug: string;
@@ -53,6 +55,7 @@ export class ThatchDB {
53
55
  #db: Database;
54
56
  #predictions: PredictionEngine;
55
57
  #behaviors: BehaviorEngine;
58
+ #chat: ChatStore;
56
59
 
57
60
  constructor(path: string) {
58
61
  this.#db = new Database(path, { create: true });
@@ -66,6 +69,7 @@ export class ThatchDB {
66
69
  this.#db.run("PRAGMA foreign_keys = ON");
67
70
  this.#predictions = new PredictionEngine(this.#db);
68
71
  this.#behaviors = new BehaviorEngine(this.#db);
72
+ this.#chat = new ChatStore(this.#db);
69
73
  this.#initSchema();
70
74
  }
71
75
 
@@ -218,9 +222,80 @@ export class ThatchDB {
218
222
  )
219
223
  `);
220
224
 
225
+ // Cross-session chat: the machine-wide session directory and message
226
+ // inbox, shared by every opencode process (delivery itself stays local
227
+ // to each host process - see src/chat.ts). Display-name uniqueness is
228
+ // case-insensitive ("Landru" and "landru" are one name), so two visually
229
+ // identical identities cannot coexist. Message endpoints are plain
230
+ // columns, not foreign keys, on purpose: unregistering a session must
231
+ // not be blocked by message history, and a departed sender degrades to
232
+ // an unknown name in the reader's view rather than vanishing rows.
233
+ this.#db.run(`
234
+ CREATE TABLE IF NOT EXISTS chat_sessions (
235
+ session_id TEXT PRIMARY KEY,
236
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
237
+ topic TEXT,
238
+ project TEXT,
239
+ host_kind TEXT NOT NULL DEFAULT ('opencode'),
240
+ registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
241
+ last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
242
+ )
243
+ `);
244
+
245
+ // delivered_at is the last wake-prompt stamp: set when the recipient's
246
+ // host accepts a nudge, restamped on re-nudges (which resets the
247
+ // re-nudge timer). read_at is set when the recipient drains its inbox.
248
+ // via_broadcast marks rows a chat_broadcast fan-out created, so the
249
+ // tail feed can render one broadcast line instead of N direct sends.
250
+ this.#db.run(`
251
+ CREATE TABLE IF NOT EXISTS chat_messages (
252
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
253
+ from_session TEXT NOT NULL,
254
+ to_session TEXT NOT NULL,
255
+ body TEXT NOT NULL,
256
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
257
+ delivered_at TEXT,
258
+ read_at TEXT,
259
+ via_broadcast INTEGER NOT NULL DEFAULT 0
260
+ )
261
+ `);
262
+
221
263
  this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
222
264
 
223
265
  this.#migrateColumns();
266
+ this.#migrateChatNameCollation();
267
+ this.#migrateChatTopic();
268
+ this.#migrateChatHostKind();
269
+ this.#migrateChatBroadcastFlag();
270
+ }
271
+
272
+ // chat_sessions tables created before the host_kind column lack it; the
273
+ // ALTER adds it, and existing rows read as opencode (which they were).
274
+ #migrateChatHostKind(): void {
275
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
276
+ if (cols.length > 0 && !cols.includes("host_kind")) {
277
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN host_kind TEXT NOT NULL DEFAULT 'opencode'");
278
+ }
279
+ }
280
+
281
+ // chat_messages tables created before the via_broadcast column lack it;
282
+ // the ALTER adds it, and existing rows read as direct sends (which they
283
+ // were).
284
+ #migrateChatBroadcastFlag(): void {
285
+ const cols = (this.#db.query("PRAGMA table_info(chat_messages)").all() as any[]).map((r) => r.name);
286
+ if (cols.length > 0 && !cols.includes("via_broadcast")) {
287
+ this.#db.run("ALTER TABLE chat_messages ADD COLUMN via_broadcast INTEGER NOT NULL DEFAULT 0");
288
+ }
289
+ }
290
+
291
+ // chat_sessions tables created before the topic column lack it; the
292
+ // ALTER adds it, and rows degrade to a NULL topic (rendered as no topic
293
+ // in chat_list).
294
+ #migrateChatTopic(): void {
295
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
296
+ if (cols.length > 0 && !cols.includes("topic")) {
297
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN topic TEXT");
298
+ }
224
299
  }
225
300
 
226
301
  // Databases created before recall telemetry lack these columns; the CREATE
@@ -241,6 +316,43 @@ export class ThatchDB {
241
316
  }
242
317
  }
243
318
 
319
+ // A chat_sessions table created before the NOCASE uniqueness change has a
320
+ // case-SENSITIVE unique constraint, which let "Landru" and "landru"
321
+ // coexist as distinct identities. SQLite cannot ALTER a column
322
+ // constraint, so the repair is a table rebuild. Detection reads the
323
+ // stored CREATE statement from sqlite_master: the NOCASE schema text
324
+ // contains COLLATE NOCASE, the older one does not. Colliding rows
325
+ // collapse via INSERT OR IGNORE (one row wins per case group; scan order
326
+ // decides which). chat_messages has no foreign key into this table, so
327
+ // message history survives untouched.
328
+ //
329
+ // ORDERING TRAP: the rebuild's CREATE and SELECT are pinned to the
330
+ // columns the OLDEST chat_sessions schema had, because a legacy table
331
+ // only has those columns - a SELECT naming a newer column would throw.
332
+ // This migration must run BEFORE any ALTER-based chat_sessions migration
333
+ // (which add newer columns afterward); when adding a column to
334
+ // chat_sessions, extend the ALTER-based migrations, not this SELECT.
335
+ #migrateChatNameCollation(): void {
336
+ const row = this.#db
337
+ .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chat_sessions'")
338
+ .get() as any;
339
+ if (!row?.sql || /COLLATE\s+NOCASE/i.test(row.sql)) return;
340
+ this.transaction(() => {
341
+ this.#db.run(`
342
+ CREATE TABLE chat_sessions_migrated (
343
+ session_id TEXT PRIMARY KEY,
344
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
345
+ project TEXT,
346
+ registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
347
+ last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
348
+ )
349
+ `);
350
+ this.#db.run("INSERT OR IGNORE INTO chat_sessions_migrated SELECT session_id, name, project, registered_at, last_seen FROM chat_sessions");
351
+ this.#db.run("DROP TABLE chat_sessions");
352
+ this.#db.run("ALTER TABLE chat_sessions_migrated RENAME TO chat_sessions");
353
+ });
354
+ }
355
+
244
356
  // ---------------------------------------------------------------------------
245
357
  // Stores
246
358
  // ---------------------------------------------------------------------------
@@ -612,7 +724,7 @@ export class ThatchDB {
612
724
  return this.#predictions.createMatcher(store, description, embedding, model);
613
725
  }
614
726
 
615
- findNearestPrediction(store: string, embedding: Float32Array, threshold: number) {
727
+ findNearestPrediction(store: string | string[], embedding: Float32Array, threshold: number) {
616
728
  return this.#predictions.findNearestPrediction(store, embedding, threshold);
617
729
  }
618
730
 
@@ -672,7 +784,7 @@ export class ThatchDB {
672
784
  return this.#behaviors.createBehaviorMatcher(store, description, embedding, model);
673
785
  }
674
786
 
675
- findNearestBehavior(store: string, embedding: Float32Array, threshold: number) {
787
+ findNearestBehavior(store: string | string[], embedding: Float32Array, threshold: number) {
676
788
  return this.#behaviors.findNearestBehavior(store, embedding, threshold);
677
789
  }
678
790
 
@@ -708,6 +820,73 @@ export class ThatchDB {
708
820
  return this.#behaviors.listBehaviors(store);
709
821
  }
710
822
 
823
+ // ---------------------------------------------------------------------------
824
+ // Cross-session chat: delegates to ChatStore
825
+ // ---------------------------------------------------------------------------
826
+
827
+ registerChatSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind) {
828
+ return this.#chat.register(sessionID, name, project, topic, kind);
829
+ }
830
+
831
+ assignChatName(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind) {
832
+ return this.#chat.assign(sessionID, project, topic, kind);
833
+ }
834
+
835
+ broadcastChatMessage(fromSession: string, body: string) {
836
+ return this.#chat.broadcast(fromSession, body);
837
+ }
838
+
839
+ chatMessageStatus(sessionID: string) {
840
+ return this.#chat.status(sessionID);
841
+ }
842
+
843
+ chatPendingByProject(project: string) {
844
+ return this.#chat.pendingByProject(project);
845
+ }
846
+
847
+ chatMessageFeed() {
848
+ return this.#chat.messageFeed();
849
+ }
850
+
851
+ unregisterChatSession(sessionID: string) {
852
+ return this.#chat.unregister(sessionID);
853
+ }
854
+
855
+ listChatSessions() {
856
+ return this.#chat.list();
857
+ }
858
+
859
+ findChatSession(nameOrID: string) {
860
+ // Facade surface: production resolves recipients inside send()/find();
861
+ // this delegation exists so callers (and tests) can resolve a session
862
+ // by name or ID without reaching past the ThatchDB API.
863
+ return this.#chat.find(nameOrID);
864
+ }
865
+
866
+ sendChatMessage(fromSession: string, toNameOrID: string, body: string) {
867
+ return this.#chat.send(fromSession, toNameOrID, body);
868
+ }
869
+
870
+ readChatMessages(sessionID: string) {
871
+ return this.#chat.read(sessionID);
872
+ }
873
+
874
+ unreadChatCount(sessionID: string) {
875
+ return this.#chat.unreadCount(sessionID);
876
+ }
877
+
878
+ heartbeatChatSessions(sessionIDs: string[]) {
879
+ return this.#chat.heartbeat(sessionIDs);
880
+ }
881
+
882
+ pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string) {
883
+ return this.#chat.pendingNotifications(sessionIDs, renudgeCutoff);
884
+ }
885
+
886
+ markChatDelivered(ids: number[]) {
887
+ return this.#chat.markDelivered(ids);
888
+ }
889
+
711
890
  // ---------------------------------------------------------------------------
712
891
  // Hygiene - signals for the session-start heartbeat. Staleness means
713
892
  // neither written nor recalled since the cutoff; recall telemetry keeps
package/src/embeddings.ts CHANGED
@@ -25,11 +25,99 @@ export interface EmbeddingModel {
25
25
  */
26
26
  export type PipelineFactory = (modelName: string) => Promise<any>;
27
27
 
28
+ /**
29
+ * Embedding backend mode. "wasm" runs the ONNX model on onnxruntime-web's
30
+ * pure-JS/wasm runtime; "native" runs it on onnxruntime-node's NAPI addon.
31
+ */
32
+ export type EmbeddingBackendMode = "wasm" | "native";
33
+
34
+ /**
35
+ * Reads the backend override. Only "native" opts out; any other value,
36
+ * including unset, selects the wasm default. Exported for tests.
37
+ */
38
+ export function backendMode(): EmbeddingBackendMode {
39
+ return process.env.THATCH_EMBEDDING_BACKEND === "native" ? "native" : "wasm";
40
+ }
41
+
42
+ const ORT_SYMBOL = Symbol.for("onnxruntime");
43
+
44
+ let backendConfigured: Promise<EmbeddingBackendMode> | null = null;
45
+
46
+ /**
47
+ * Chooses the ONNX backend before transformers initializes. Memoized: the
48
+ * choice is made once at transformers' module init and cannot be changed
49
+ * afterwards. Exported for tests.
50
+ *
51
+ * "wasm" mode pins globalThis[Symbol.for("onnxruntime")] to onnxruntime-web
52
+ * so transformers' node build creates sessions on its pure-JS/wasm runtime
53
+ * instead of the onnxruntime-node NAPI addon. The NAPI addon leaves wrap
54
+ * finalizers that panic Bun at process teardown (oven-sh/bun#34664); the wasm
55
+ * runtime creates none. A backend already chosen by the host (symbol present)
56
+ * is respected, never clobbered. A failed onnxruntime-web import falls back
57
+ * to native rather than blocking embedding.
58
+ */
59
+ export async function configureBackend(): Promise<EmbeddingBackendMode> {
60
+ backendConfigured ??= (async () => {
61
+ const mode = backendMode();
62
+ if (mode === "native" || ORT_SYMBOL in globalThis) return mode;
63
+ try {
64
+ const ort = await import("onnxruntime-web");
65
+ (globalThis as Record<symbol, unknown>)[ORT_SYMBOL] = ort;
66
+ // Single-threaded wasm: deterministic, and avoids worker_threads,
67
+ // where Bun has known shutdown crashes. Latency at one thread is fine
68
+ // for background embedding.
69
+ ort.env.wasm.numThreads = 1;
70
+ } catch {
71
+ // onnxruntime-web unavailable - native sessions still work.
72
+ }
73
+ return mode;
74
+ })();
75
+ return backendConfigured;
76
+ }
77
+
78
+ /**
79
+ * Test hook: clears the configureBackend memo and unpins the onnxruntime
80
+ * global, so a test can exercise the pinning path itself. Needed because the
81
+ * test process is shared across files: initializing the plugin server seeds
82
+ * behaviors, which embeds, which runs configureBackend and pins the symbol
83
+ * as a side effect. Whether that happened before a given test file runs
84
+ * depends on bun's file order, which differs between machines.
85
+ */
86
+ export function resetBackendForTests(): void {
87
+ backendConfigured = null;
88
+ delete (globalThis as Record<symbol, unknown>)[ORT_SYMBOL];
89
+ }
90
+
28
91
  const defaultPipelineFactory: PipelineFactory = async (modelName) => {
92
+ const mode = await configureBackend();
29
93
  const { pipeline } = await import("@huggingface/transformers");
30
- return pipeline("feature-extraction", modelName);
94
+ if (mode === "native") {
95
+ return pipeline("feature-extraction", modelName);
96
+ }
97
+ // The ORT_SYMBOL override branch leaves transformers' device allowlist
98
+ // empty, so any named device throws; "auto" bypasses the check and the
99
+ // explicit execution provider does the real work.
100
+ return pipeline("feature-extraction", modelName, {
101
+ device: "auto",
102
+ session_options: { executionProviders: ["wasm"] },
103
+ });
31
104
  };
32
105
 
106
+ /**
107
+ * How long a loaded pipeline may sit unused before its native ONNX sessions
108
+ * are released while the runtime is still healthy. Long enough to span the
109
+ * gap between extraction bursts, short enough to matter before process exit.
110
+ */
111
+ const DEFAULT_IDLE_TTL_MS = 10 * 60 * 1000;
112
+
113
+ export interface BgeEmbeddingModelOptions {
114
+ /**
115
+ * Idle milliseconds before the pipeline's native sessions are released.
116
+ * A later embed lazily re-loads. 0 disables idle release.
117
+ */
118
+ idleTtlMs?: number;
119
+ }
120
+
33
121
  /**
34
122
  * Lazy-loads an embedding model via @huggingface/transformers.
35
123
  * Model files (~34 MB for the default) are downloaded once and cached by HF Hub.
@@ -37,12 +125,19 @@ const defaultPipelineFactory: PipelineFactory = async (modelName) => {
37
125
  export class BgeEmbeddingModel implements EmbeddingModel {
38
126
  #modelName: string;
39
127
  #pipelineFactory: PipelineFactory;
128
+ #idleTtlMs: number;
40
129
  #pipe: any = null;
41
130
  #loading: Promise<void> | null = null;
131
+ #idleTimer: ReturnType<typeof setTimeout> | null = null;
42
132
 
43
- constructor(modelName = "Xenova/bge-small-en-v1.5", pipelineFactory?: PipelineFactory) {
133
+ constructor(
134
+ modelName = "Xenova/bge-small-en-v1.5",
135
+ pipelineFactory?: PipelineFactory,
136
+ options: BgeEmbeddingModelOptions = {},
137
+ ) {
44
138
  this.#modelName = modelName;
45
139
  this.#pipelineFactory = pipelineFactory ?? defaultPipelineFactory;
140
+ this.#idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
46
141
  }
47
142
 
48
143
  get loaded(): boolean {
@@ -75,12 +170,31 @@ export class BgeEmbeddingModel implements EmbeddingModel {
75
170
  }
76
171
 
77
172
  async #embed(text: string): Promise<Float32Array> {
78
- await this.load();
79
- const output = await this.#pipe(text, {
80
- pooling: "mean",
81
- normalize: true,
82
- });
83
- return output.data as Float32Array;
173
+ // Clear any pending idle timer before touching the pipeline. The timer
174
+ // must never fire while an embed is in flight - its callback releases
175
+ // the pipeline this call is about to use.
176
+ this.#clearIdleTimer();
177
+ try {
178
+ await this.load();
179
+ const pipe = this.#pipe;
180
+ const output = await pipe(text, {
181
+ pooling: "mean",
182
+ normalize: true,
183
+ });
184
+ // Copy the vector before releasing the tensor: the real Tensor's data
185
+ // getter exposes memory the disposer frees.
186
+ const data = new Float32Array(output.data);
187
+ if (typeof output?.dispose === "function") {
188
+ try {
189
+ await output.dispose();
190
+ } catch {
191
+ // Tensor release is best-effort; the copied data is already valid.
192
+ }
193
+ }
194
+ return data;
195
+ } finally {
196
+ this.#armIdleTimer();
197
+ }
84
198
  }
85
199
 
86
200
  /**
@@ -89,11 +203,16 @@ export class BgeEmbeddingModel implements EmbeddingModel {
89
203
  * the runtime is still alive. If instead they survive to Bun's worker
90
204
  * teardown, Bun panics creating their errors (oven-sh/bun#34664).
91
205
  *
206
+ * The idle timer releases on the same principle during normal operation:
207
+ * the plugin's dispose hook is best-effort because opencode can exit
208
+ * without running it, so sessions are also freed after a quiet period.
209
+ *
92
210
  * Best-effort by design: dispose never throws, and a failed release is
93
211
  * indistinguishable from never loading. An embed call after dispose just
94
212
  * lazily re-loads the model.
95
213
  */
96
214
  async dispose(): Promise<void> {
215
+ this.#clearIdleTimer();
97
216
  // An in-flight load memoizes a rejecting promise on failure. Await it
98
217
  // defensively so its error neither escapes dispose nor kills the teardown.
99
218
  if (this.#loading) {
@@ -103,6 +222,10 @@ export class BgeEmbeddingModel implements EmbeddingModel {
103
222
  // Load failed - nothing was loaded, so nothing to release.
104
223
  }
105
224
  }
225
+ await this.#release();
226
+ }
227
+
228
+ async #release(): Promise<void> {
106
229
  const pipe = this.#pipe;
107
230
  this.#pipe = null;
108
231
  this.#loading = null;
@@ -110,7 +233,27 @@ export class BgeEmbeddingModel implements EmbeddingModel {
110
233
  try {
111
234
  await pipe.dispose();
112
235
  } catch {
113
- // Best-effort - see doc comment above.
236
+ // Best-effort - see dispose's doc comment.
237
+ }
238
+ }
239
+
240
+ #armIdleTimer(): void {
241
+ if (this.#idleTtlMs <= 0) return;
242
+ this.#clearIdleTimer();
243
+ this.#idleTimer = setTimeout(() => {
244
+ this.#idleTimer = null;
245
+ this.#release().catch(() => {
246
+ // Idle release is best-effort; a failed release retries on dispose.
247
+ });
248
+ }, this.#idleTtlMs);
249
+ // Never hold the host process open for an idle timer.
250
+ this.#idleTimer.unref?.();
251
+ }
252
+
253
+ #clearIdleTimer(): void {
254
+ if (this.#idleTimer) {
255
+ clearTimeout(this.#idleTimer);
256
+ this.#idleTimer = null;
114
257
  }
115
258
  }
116
259
  }
package/src/index.ts CHANGED
@@ -22,7 +22,8 @@ import { hygieneReport } from "./hygiene";
22
22
  import { seedDefaultBehaviors } from "./seed-behaviors";
23
23
  import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
24
24
  import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
25
- import { watcherNotificationNudge } from "./prompts";
25
+ import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
26
+ import { ChatPoller } from "./chat";
26
27
  import pkg from "../package.json";
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -86,9 +87,12 @@ export const server: Plugin = async ({ client, worktree }) => {
86
87
  const runningVersion = pkg.version;
87
88
 
88
89
  // Latest observed opencode session status per session ("busy" | "idle" |
89
- // "retry"). The event hook records these; the watcher registry's
90
- // canDeliver gate reads them so proactive notification prompts only fire
91
- // into idle sessions - never into a turn that is already running.
90
+ // "retry"). Keys enter from session.status events and leave only in
91
+ // session.deleted (which shrinks the chat poller's hosted set). Readers:
92
+ // the watcher registry's and the chat poller's canDeliver gates
93
+ // (proactive prompts only fire into idle sessions, never into a running
94
+ // turn), and the chat poller's hostedSessions set (the sessions this
95
+ // process may deliver chat mail to - the map's keys).
92
96
  const sessionStatus = new Map<string, string>();
93
97
 
94
98
  // In-memory watcher registry for proactive event notifications (GitHub PR
@@ -140,6 +144,50 @@ export const server: Plugin = async ({ client, worktree }) => {
140
144
  });
141
145
  watchers.start();
142
146
 
147
+ // Cross-session chat delivery. The inbox is shared SQLite state (any
148
+ // process reads/writes it), but this poller only ever delivers to sessions
149
+ // hosted by THIS process - the recipient's host is the single deliverer,
150
+ // so there is no cross-process double-delivery race. Wake prompts use the
151
+ // same synthetic-part mechanism as watcher notifications; the toast
152
+ // covers the TUI-hidden part so the user sees why the model woke up.
153
+ // canDeliver closes over `compacting` (declared below): the closure reads
154
+ // it at call time, after full initialization, same as the watcher gate.
155
+ const chatPoller = new ChatPoller({
156
+ store: db,
157
+ hostedSessions: () => {
158
+ // Sub-agent children share this process's sessionStatus (their
159
+ // status events land here), but they are never legitimate chat
160
+ // participants - a registered child would be heartbeat-ed
161
+ // fresh-forever and burn nudge budget on undeliverable wake
162
+ // prompts. Keep them out of the hosted set even when guidance
163
+ // against registering them is ignored.
164
+ const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
165
+ return hosted;
166
+ },
167
+ deliver: async (sessionID, senders, count) => {
168
+ await client.session.promptAsync({
169
+ path: { id: sessionID },
170
+ body: {
171
+ parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
172
+ },
173
+ });
174
+ try {
175
+ await client.tui.showToast({
176
+ body: {
177
+ message: `\u{1F4AC} chat: ${count} unread from ${senders.join(", ")}`,
178
+ variant: "info",
179
+ duration: 5000,
180
+ },
181
+ });
182
+ } catch {
183
+ // TUI may not be connected. Best-effort.
184
+ }
185
+ },
186
+ canDeliver: (sessionID) =>
187
+ !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
188
+ });
189
+ chatPoller.start();
190
+
143
191
  // Sessions currently being compacted. chat.message nudges are skipped while
144
192
  // a session is in this set - the agent can't call tools during summary
145
193
  // generation, so a recall or extraction nudge would cause a blocked-tool
@@ -407,6 +455,40 @@ export const server: Plugin = async ({ client, worktree }) => {
407
455
  childMetrics.set(input.sessionID, metrics);
408
456
  return;
409
457
  }
458
+ // Chat conversational events echo back into the transcript as visible
459
+ // message parts. Plugin tools render as muted one-line entries with
460
+ // the output hidden behind a default-off TUI toggle, so without this
461
+ // the human watching a session never sees the conversation happen.
462
+ // Delivery is a non-synthetic noReply promptAsync: the part renders
463
+ // as a visible bubble and starts no model turn (the server's prompt
464
+ // path returns before the completion loop). Non-synthetic also means
465
+ // later turns see the echo in context - a small duplication of the
466
+ // tool call it mirrors, accepted for visibility. Fire-and-forget: an
467
+ // echo failure must never fail the tool call it follows.
468
+ if (input.tool.startsWith("thatch_chat_")) {
469
+ const echo = chatEchoText(
470
+ input.tool,
471
+ (input.args ?? {}) as Record<string, unknown>,
472
+ typeof output.output === "string" ? output.output : "",
473
+ );
474
+ if (echo) {
475
+ void client.session
476
+ .promptAsync({
477
+ path: { id: input.sessionID },
478
+ body: {
479
+ noReply: true,
480
+ parts: [{ type: "text", text: echo }],
481
+ },
482
+ })
483
+ .catch((err) => {
484
+ // The echo must never fail the tool call, but a silent catch
485
+ // would hide a systemic promptAsync failure (bubbles would
486
+ // stop appearing everywhere with no diagnostics).
487
+ console.error(`[thatch] chat echo delivery failed: ${err}`);
488
+ });
489
+ }
490
+ return;
491
+ }
410
492
  if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
411
493
  extraction.push({
412
494
  tool: input.tool,
@@ -434,6 +516,12 @@ export const server: Plugin = async ({ client, worktree }) => {
434
516
  // a summary, so a nudge that says "use thatch_memory_recall" triggers a
435
517
  // blocked-tool error.
436
518
  "chat.message": async (input, output) => {
519
+ // Chat transcript echoes are non-synthetic noReply parts, so they
520
+ // arrive here like any user message - but no model turn ever reads
521
+ // them, and the nudge machinery would embed and scan their text for
522
+ // nothing (and fire spurious toasts when thresholds cross). Skip
523
+ // them entirely.
524
+ if (isChatEchoParts(output.parts)) return;
437
525
  if (compacting.has(input.sessionID)) {
438
526
  // The session is marked as compacting. If this message is the
439
527
  // compaction summary generation itself (has a compaction-type part),
@@ -757,10 +845,22 @@ export const server: Plugin = async ({ client, worktree }) => {
757
845
  } catch (err) {
758
846
  console.error(`[thatch] watcher delivery on idle failed: ${err}`);
759
847
  }
848
+ // Same for chat messages: mail that arrived mid-turn lands now
849
+ // instead of waiting for the next poll cycle.
850
+ try {
851
+ await chatPoller.deliverPending();
852
+ } catch (err) {
853
+ console.error(`[thatch] chat delivery on idle failed: ${err}`);
854
+ }
760
855
  return;
761
856
  }
762
857
  if (event.type === "session.deleted") {
763
858
  const id = event.properties.info.id;
859
+ // Shrink the chat poller's hosted set FIRST: the rest of this
860
+ // branch runs extraction calls that could throw on a transient DB
861
+ // error, and a leaked status key would keep heartbeat-ing a dead
862
+ // session as permanently fresh.
863
+ sessionStatus.delete(id);
764
864
  // A child deleted before completing never processed its payload.
765
865
  const parentID = childToParent.get(id);
766
866
  if (parentID) {
@@ -776,8 +876,15 @@ export const server: Plugin = async ({ client, worktree }) => {
776
876
  // notifications no longer exists.
777
877
  extraction.completeAccepted(id);
778
878
  extracting.delete(id);
779
- sessionStatus.delete(id);
780
879
  watchers.cancelSession(id);
880
+ // Leaving the chat directory is the graceful-exit fast path; a
881
+ // crashed process never fires session.deleted, so the heartbeat
882
+ // staleness in chat_list is the covering signal for that case.
883
+ try {
884
+ db.unregisterChatSession(id);
885
+ } catch (err) {
886
+ console.error(`[thatch] chat unregister on delete failed: ${err}`);
887
+ }
781
888
  return;
782
889
  }
783
890
 
@@ -816,6 +923,7 @@ export const server: Plugin = async ({ client, worktree }) => {
816
923
  dispose: async () => {
817
924
  stopVersionChecker();
818
925
  watchers.dispose();
926
+ chatPoller.dispose();
819
927
  // Release native ONNX sessions while the worker is still healthy. Left
820
928
  // to Bun's teardown, their NAPI finalizers panic the process (see
821
929
  // BgeEmbeddingModel.dispose).
package/src/prediction.ts CHANGED
@@ -89,7 +89,7 @@ export class PredictionEngine {
89
89
  return this.#engine.createMatcher(store, description, embedding, model);
90
90
  }
91
91
 
92
- findNearestPrediction(store: string, embedding: Float32Array, threshold: number): PredictionRow | null {
92
+ findNearestPrediction(store: string | string[], embedding: Float32Array, threshold: number): PredictionRow | null {
93
93
  return this.#engine.findNearestItem(store, embedding, threshold) as PredictionRow | null;
94
94
  }
95
95