@jeffober/thatch 0.1.40 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/artifacts/skills/thatch-code-review.md +4 -4
- package/artifacts/skills/thatch-review-synthesizer.md +8 -8
- package/artifacts/skills/{thatch-review-mentorship.md → thatch-review-technique.md} +9 -10
- package/bin/release +4 -1
- package/bin/thatch +318 -18
- package/docs/dev/README.md +29 -15
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +33 -4
- package/docs/dev/features/cross-session-chat.md +315 -0
- package/docs/dev/features/database.md +52 -4
- package/docs/dev/features/multi-host.md +18 -9
- package/docs/dev/features/setup.md +12 -5
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +7 -4
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +5 -4
- package/docs/dev/skills.md +5 -4
- package/docs/user/README.md +25 -6
- package/docs/user/cli.md +78 -0
- package/docs/user/code-review.md +1 -1
- package/docs/user/cross-session-chat.md +150 -0
- package/docs/user/setup.md +16 -2
- package/docs/user/skills.md +7 -6
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +120 -0
- package/src/chat.ts +1210 -0
- package/src/config.ts +34 -1
- package/src/db.ts +227 -0
- package/src/index.ts +188 -17
- package/src/prompts.ts +218 -29
- package/src/setup.ts +83 -29
- package/src/skills.ts +47 -17
- package/src/tool-defs.ts +574 -31
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/src/config.ts
CHANGED
|
@@ -16,15 +16,22 @@ export const notificationPrefsSchema = z.strictObject({
|
|
|
16
16
|
sound: z.string().optional(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
export const chatPrefsSchema = z.strictObject({
|
|
20
|
+
enabled: z.boolean().optional(),
|
|
21
|
+
autoRegister: z.boolean().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
export const configSchema = z.strictObject({
|
|
20
25
|
notifications: notificationPrefsSchema.optional(),
|
|
26
|
+
chat: chatPrefsSchema.optional(),
|
|
21
27
|
});
|
|
22
28
|
|
|
23
29
|
export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
|
|
30
|
+
export type ChatPrefs = z.infer<typeof chatPrefsSchema>;
|
|
24
31
|
export type Config = z.infer<typeof configSchema>;
|
|
25
32
|
|
|
26
33
|
/** Sections in presentation order. config_get / config_set iterate this. */
|
|
27
|
-
export const CONFIG_SECTIONS = ["notifications"] as const;
|
|
34
|
+
export const CONFIG_SECTIONS = ["notifications", "chat"] as const;
|
|
28
35
|
export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
|
|
29
36
|
|
|
30
37
|
/**
|
|
@@ -90,6 +97,32 @@ export function mergeNotificationPrefs(
|
|
|
90
97
|
return { ...current, ...patch };
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/** Field-level merge for one section: omitted fields keep their values. */
|
|
101
|
+
export function mergeChatPrefs(current: ChatPrefs | undefined, patch: ChatPrefs): ChatPrefs {
|
|
102
|
+
return { ...current, ...patch };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether cross-session chat is on. Unset means on - chat is the default,
|
|
107
|
+
* and the toggle exists for users who want the feature entirely dark: every
|
|
108
|
+
* chat tool refuses, wake delivery never starts, and the system prompt
|
|
109
|
+
* omits the chat section.
|
|
110
|
+
*/
|
|
111
|
+
export function chatEnabled(config: Config): boolean {
|
|
112
|
+
return config.chat?.enabled !== false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether opencode sessions join the chat directory automatically (first
|
|
117
|
+
* idle event, name assigned by thatch). Unset means on - the directory is
|
|
118
|
+
* only useful when peers are in it, and a session that must be told to
|
|
119
|
+
* register never is. Turning it off restores opt-in behavior: sessions
|
|
120
|
+
* stay out of the directory until they call chat_register.
|
|
121
|
+
*/
|
|
122
|
+
export function chatAutoRegister(config: Config): boolean {
|
|
123
|
+
return config.chat?.autoRegister !== false;
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
/**
|
|
94
127
|
* Platform fallbacks shown by config_get and applied by notify_user when the
|
|
95
128
|
* user has not set a preference. Only darwin has opinionated defaults; other
|
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 } 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,112 @@ 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
|
+
auto INTEGER NOT NULL DEFAULT 0
|
|
243
|
+
)
|
|
244
|
+
`);
|
|
245
|
+
|
|
246
|
+
// delivered_at is the last wake-prompt stamp: set when the recipient's
|
|
247
|
+
// host accepts a nudge, restamped on re-nudges (which resets the
|
|
248
|
+
// re-nudge timer). read_at is set when the recipient drains its inbox.
|
|
249
|
+
// via_broadcast marks rows a chat_broadcast fan-out created, so the
|
|
250
|
+
// tail feed can render one broadcast line instead of N direct sends.
|
|
251
|
+
this.#db.run(`
|
|
252
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
253
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
254
|
+
from_session TEXT NOT NULL,
|
|
255
|
+
to_session TEXT NOT NULL,
|
|
256
|
+
body TEXT NOT NULL,
|
|
257
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
258
|
+
delivered_at TEXT,
|
|
259
|
+
read_at TEXT,
|
|
260
|
+
via_broadcast INTEGER NOT NULL DEFAULT 0
|
|
261
|
+
)
|
|
262
|
+
`);
|
|
263
|
+
|
|
221
264
|
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
|
|
222
265
|
|
|
266
|
+
// Per-base name counters for assigned chat names. A row only ever
|
|
267
|
+
// increments (never on prune), so a name is minted exactly once.
|
|
268
|
+
this.#db.run(`
|
|
269
|
+
CREATE TABLE IF NOT EXISTS chat_name_counters (
|
|
270
|
+
base TEXT PRIMARY KEY,
|
|
271
|
+
next INTEGER NOT NULL DEFAULT 1
|
|
272
|
+
)
|
|
273
|
+
`);
|
|
274
|
+
|
|
275
|
+
// Explicit leaves (chat_unregister). Suppresses auto-registration so
|
|
276
|
+
// the next idle event cannot silently re-register the session; cleared
|
|
277
|
+
// when the session explicitly rejoins via chat_register. Survives the
|
|
278
|
+
// directory-row delete, which is the point.
|
|
279
|
+
this.#db.run(`
|
|
280
|
+
CREATE TABLE IF NOT EXISTS chat_leave_tombstones (
|
|
281
|
+
session_id TEXT PRIMARY KEY,
|
|
282
|
+
left_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
283
|
+
)
|
|
284
|
+
`);
|
|
285
|
+
|
|
223
286
|
this.#migrateColumns();
|
|
287
|
+
this.#migrateChatNameCollation();
|
|
288
|
+
this.#migrateChatTopic();
|
|
289
|
+
this.#migrateChatHostKind();
|
|
290
|
+
this.#migrateChatBroadcastFlag();
|
|
291
|
+
this.#migrateChatAuto();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
295
|
+
// adds it, and existing rows read as manual (which they were - auto is
|
|
296
|
+
// only set by the assigned-name registration path).
|
|
297
|
+
#migrateChatAuto(): void {
|
|
298
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
299
|
+
if (cols.length > 0 && !cols.includes("auto")) {
|
|
300
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN auto INTEGER NOT NULL DEFAULT 0");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// chat_sessions tables created before the host_kind column lack it; the
|
|
305
|
+
// ALTER adds it, and existing rows read as opencode (which they were).
|
|
306
|
+
#migrateChatHostKind(): void {
|
|
307
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
308
|
+
if (cols.length > 0 && !cols.includes("host_kind")) {
|
|
309
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN host_kind TEXT NOT NULL DEFAULT 'opencode'");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// chat_messages tables created before the via_broadcast column lack it;
|
|
314
|
+
// the ALTER adds it, and existing rows read as direct sends (which they
|
|
315
|
+
// were).
|
|
316
|
+
#migrateChatBroadcastFlag(): void {
|
|
317
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_messages)").all() as any[]).map((r) => r.name);
|
|
318
|
+
if (cols.length > 0 && !cols.includes("via_broadcast")) {
|
|
319
|
+
this.#db.run("ALTER TABLE chat_messages ADD COLUMN via_broadcast INTEGER NOT NULL DEFAULT 0");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// chat_sessions tables created before the topic column lack it; the
|
|
324
|
+
// ALTER adds it, and rows degrade to a NULL topic (rendered as no topic
|
|
325
|
+
// in chat_list).
|
|
326
|
+
#migrateChatTopic(): void {
|
|
327
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
328
|
+
if (cols.length > 0 && !cols.includes("topic")) {
|
|
329
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN topic TEXT");
|
|
330
|
+
}
|
|
224
331
|
}
|
|
225
332
|
|
|
226
333
|
// Databases created before recall telemetry lack these columns; the CREATE
|
|
@@ -241,6 +348,43 @@ export class ThatchDB {
|
|
|
241
348
|
}
|
|
242
349
|
}
|
|
243
350
|
|
|
351
|
+
// A chat_sessions table created before the NOCASE uniqueness change has a
|
|
352
|
+
// case-SENSITIVE unique constraint, which let "Landru" and "landru"
|
|
353
|
+
// coexist as distinct identities. SQLite cannot ALTER a column
|
|
354
|
+
// constraint, so the repair is a table rebuild. Detection reads the
|
|
355
|
+
// stored CREATE statement from sqlite_master: the NOCASE schema text
|
|
356
|
+
// contains COLLATE NOCASE, the older one does not. Colliding rows
|
|
357
|
+
// collapse via INSERT OR IGNORE (one row wins per case group; scan order
|
|
358
|
+
// decides which). chat_messages has no foreign key into this table, so
|
|
359
|
+
// message history survives untouched.
|
|
360
|
+
//
|
|
361
|
+
// ORDERING TRAP: the rebuild's CREATE and SELECT are pinned to the
|
|
362
|
+
// columns the OLDEST chat_sessions schema had, because a legacy table
|
|
363
|
+
// only has those columns - a SELECT naming a newer column would throw.
|
|
364
|
+
// This migration must run BEFORE any ALTER-based chat_sessions migration
|
|
365
|
+
// (which add newer columns afterward); when adding a column to
|
|
366
|
+
// chat_sessions, extend the ALTER-based migrations, not this SELECT.
|
|
367
|
+
#migrateChatNameCollation(): void {
|
|
368
|
+
const row = this.#db
|
|
369
|
+
.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chat_sessions'")
|
|
370
|
+
.get() as any;
|
|
371
|
+
if (!row?.sql || /COLLATE\s+NOCASE/i.test(row.sql)) return;
|
|
372
|
+
this.transaction(() => {
|
|
373
|
+
this.#db.run(`
|
|
374
|
+
CREATE TABLE chat_sessions_migrated (
|
|
375
|
+
session_id TEXT PRIMARY KEY,
|
|
376
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
377
|
+
project TEXT,
|
|
378
|
+
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
379
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
380
|
+
)
|
|
381
|
+
`);
|
|
382
|
+
this.#db.run("INSERT OR IGNORE INTO chat_sessions_migrated SELECT session_id, name, project, registered_at, last_seen FROM chat_sessions");
|
|
383
|
+
this.#db.run("DROP TABLE chat_sessions");
|
|
384
|
+
this.#db.run("ALTER TABLE chat_sessions_migrated RENAME TO chat_sessions");
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
244
388
|
// ---------------------------------------------------------------------------
|
|
245
389
|
// Stores
|
|
246
390
|
// ---------------------------------------------------------------------------
|
|
@@ -708,6 +852,89 @@ export class ThatchDB {
|
|
|
708
852
|
return this.#behaviors.listBehaviors(store);
|
|
709
853
|
}
|
|
710
854
|
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// Cross-session chat: delegates to ChatStore
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null) {
|
|
860
|
+
return this.#chat.register(sessionID, project, topic, kind, nameBase);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
refreshChatTopic(sessionID: string, title: string) {
|
|
864
|
+
return this.#chat.refreshAutoTopic(sessionID, title);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
pruneStaleChatAuto(cutoff: string) {
|
|
868
|
+
return this.#chat.pruneStaleAuto(cutoff);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
hasChatLeaveTombstone(sessionID: string) {
|
|
872
|
+
return this.#chat.hasLeaveTombstone(sessionID);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
clearChatLeaveTombstone(sessionID: string) {
|
|
876
|
+
return this.#chat.clearLeaveTombstone(sessionID);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
chatLeaveSuperseded(sessionID: string, project: string | null) {
|
|
880
|
+
return this.#chat.leaveSuperseded(sessionID, project);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
broadcastChatMessage(fromSession: string, body: string) {
|
|
884
|
+
return this.#chat.broadcast(fromSession, body);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
chatMessageStatus(sessionID: string) {
|
|
888
|
+
return this.#chat.status(sessionID);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
chatPendingByProject(project: string) {
|
|
892
|
+
return this.#chat.pendingByProject(project);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
chatMessageFeed() {
|
|
896
|
+
return this.#chat.messageFeed();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
unregisterChatSession(sessionID: string) {
|
|
900
|
+
return this.#chat.unregister(sessionID);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
listChatSessions() {
|
|
904
|
+
return this.#chat.list();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
findChatSession(nameOrID: string) {
|
|
908
|
+
// Facade surface: production resolves recipients inside send()/find();
|
|
909
|
+
// this delegation exists so callers (and tests) can resolve a session
|
|
910
|
+
// by name or ID without reaching past the ThatchDB API.
|
|
911
|
+
return this.#chat.find(nameOrID);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
sendChatMessage(fromSession: string, toNameOrID: string, body: string) {
|
|
915
|
+
return this.#chat.send(fromSession, toNameOrID, body);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
readChatMessages(sessionID: string) {
|
|
919
|
+
return this.#chat.read(sessionID);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
unreadChatCount(sessionID: string) {
|
|
923
|
+
return this.#chat.unreadCount(sessionID);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
heartbeatChatSessions(sessionIDs: string[]) {
|
|
927
|
+
return this.#chat.heartbeat(sessionIDs);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string) {
|
|
931
|
+
return this.#chat.pendingNotifications(sessionIDs, renudgeCutoff);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
markChatDelivered(ids: number[]) {
|
|
935
|
+
return this.#chat.markDelivered(ids);
|
|
936
|
+
}
|
|
937
|
+
|
|
711
938
|
// ---------------------------------------------------------------------------
|
|
712
939
|
// Hygiene - signals for the session-start heartbeat. Staleness means
|
|
713
940
|
// neither written nor recalled since the cutoff; recall telemetry keeps
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,9 @@ 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, isDefaultSessionTitle, slugifyTitle } from "./chat";
|
|
27
|
+
import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
|
|
26
28
|
import pkg from "../package.json";
|
|
27
29
|
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
@@ -59,6 +61,12 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
59
61
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
60
62
|
const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
|
|
61
63
|
|
|
64
|
+
// Whether cross-session chat is on (chat.enabled, default on). Read once
|
|
65
|
+
// at init: it gates the chat poller and the prompt's chat section. The
|
|
66
|
+
// chat tools re-read the file per call, so a toggle takes effect there
|
|
67
|
+
// without a restart.
|
|
68
|
+
const chatOn = chatEnabled(loadConfig(dbPath).config);
|
|
69
|
+
|
|
62
70
|
const db = new ThatchDB(dbPath);
|
|
63
71
|
const model = new BgeEmbeddingModel(modelName);
|
|
64
72
|
const extraction = new ExtractionPipeline();
|
|
@@ -86,28 +94,57 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
86
94
|
const runningVersion = pkg.version;
|
|
87
95
|
|
|
88
96
|
// Latest observed opencode session status per session ("busy" | "idle" |
|
|
89
|
-
// "retry").
|
|
90
|
-
//
|
|
91
|
-
//
|
|
97
|
+
// "retry"). Keys enter from session.status events and leave only in
|
|
98
|
+
// session.deleted (which shrinks the chat poller's hosted set). Readers:
|
|
99
|
+
// the watcher registry's and the chat poller's canDeliver gates
|
|
100
|
+
// (proactive prompts only fire into idle sessions, never into a running
|
|
101
|
+
// turn), and the chat poller's hostedSessions set (the sessions this
|
|
102
|
+
// process may deliver chat mail to - the map's keys).
|
|
92
103
|
const sessionStatus = new Map<string, string>();
|
|
93
104
|
|
|
105
|
+
// The proactive-prompt gate, shared by both delivery registries (watchers
|
|
106
|
+
// and chat). Two layers, in cost order: the compacting set and the
|
|
107
|
+
// event-fed sessionStatus map answer cheaply, and a final check verifies
|
|
108
|
+
// against the server's live status - the map is only as fresh as the
|
|
109
|
+
// event stream, and a stale "idle" turns a wake into mid-turn context
|
|
110
|
+
// injection (observed live: a session read mail mid-burst, contradicted
|
|
111
|
+
// its own in-flight plan, and looped).
|
|
112
|
+
const canPromptSession = async (sessionID: string): Promise<boolean> => {
|
|
113
|
+
if (compacting.has(sessionID)) return false;
|
|
114
|
+
if (sessionStatus.get(sessionID) !== "idle") return false;
|
|
115
|
+
try {
|
|
116
|
+
const { data } = await client.session.status();
|
|
117
|
+
// The server's status map only carries ACTIVE sessions - it deletes
|
|
118
|
+
// the entry when a session goes idle (session/status.ts), so absent
|
|
119
|
+
// means idle. Fail closed on busy/retry, pass on idle-or-absent.
|
|
120
|
+
const live = data?.[sessionID];
|
|
121
|
+
return !live || live.type === "idle";
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Unreachable server: do not deliver (the mail stays pending).
|
|
124
|
+
console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
94
129
|
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
95
|
-
//
|
|
96
|
-
// state. See src/watchers.ts
|
|
130
|
+
// and branch watching, plus local command watches). Deliberately
|
|
131
|
+
// process-scoped: no SQLite, no cross-restart state. See src/watchers.ts
|
|
132
|
+
// for the rationale.
|
|
97
133
|
//
|
|
98
134
|
// Delivery prompts the session with a synthetic part - the same mechanism
|
|
99
135
|
// opencode uses for background task completions - so a watched event
|
|
100
|
-
// triggers a model turn even when the user is away. Events
|
|
101
|
-
//
|
|
136
|
+
// triggers a model turn even when the user is away. Events carry pointer
|
|
137
|
+
// data plus machine status (check conclusions, exit codes); the model
|
|
138
|
+
// fetches logs and further details itself with gh.
|
|
102
139
|
const watchers = new WatcherRegistry({
|
|
103
140
|
deliver: async (sessionID, events) => {
|
|
104
141
|
// Events carry their watch's target label from the registry, so the
|
|
105
142
|
// notification header is correct for every source (PRs, branches,
|
|
106
|
-
// CI events whose URLs would not parse).
|
|
143
|
+
// commands, CI events whose URLs would not parse).
|
|
107
144
|
await client.session.promptAsync({
|
|
108
145
|
path: { id: sessionID },
|
|
109
146
|
body: {
|
|
110
|
-
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
|
|
147
|
+
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true }],
|
|
111
148
|
},
|
|
112
149
|
});
|
|
113
150
|
// Toast: the notification part is TUI-hidden, so without this the
|
|
@@ -129,17 +166,61 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
129
166
|
// TUI may not be connected. Best-effort.
|
|
130
167
|
}
|
|
131
168
|
},
|
|
132
|
-
canDeliver:
|
|
133
|
-
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
169
|
+
canDeliver: canPromptSession,
|
|
134
170
|
ghRunner: ghApiRun,
|
|
135
171
|
});
|
|
136
|
-
// gh presence decides whether watch_create
|
|
137
|
-
//
|
|
172
|
+
// gh presence decides whether watch_create and watch_branch_create work;
|
|
173
|
+
// checked lazily by the tools, but log once at startup so misconfiguration
|
|
174
|
+
// is visible in debug logs. watch_command_create needs only bash, not gh.
|
|
138
175
|
void ghAvailable().then((ok) => {
|
|
139
|
-
if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
|
|
176
|
+
if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
|
|
140
177
|
});
|
|
141
178
|
watchers.start();
|
|
142
179
|
|
|
180
|
+
// The proactive-prompt gate, shared by both delivery registries
|
|
181
|
+
// (watchers and chat). Two layers, in cost order: the compacting set and
|
|
182
|
+
// the event-fed sessionStatus map answer cheaply, and a final check
|
|
183
|
+
// verifies against the server's live status - the map is only as fresh
|
|
184
|
+
// as the event stream, and a stale "idle" turns a wake into mid-turn
|
|
185
|
+
// context injection (observed live: a session read mail mid-burst,
|
|
186
|
+
// contradicted its own in-flight plan, and looped).
|
|
187
|
+
const chatPoller = new ChatPoller({
|
|
188
|
+
store: db,
|
|
189
|
+
hostedSessions: () => {
|
|
190
|
+
// Sub-agent children share this process's sessionStatus (their
|
|
191
|
+
// status events land here), but they are never legitimate chat
|
|
192
|
+
// participants - a registered child would be heartbeat-ed
|
|
193
|
+
// fresh-forever and burn nudge budget on undeliverable wake
|
|
194
|
+
// prompts. Keep them out of the hosted set even when guidance
|
|
195
|
+
// against registering them is ignored.
|
|
196
|
+
const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
|
|
197
|
+
return hosted;
|
|
198
|
+
},
|
|
199
|
+
deliver: async (sessionID, senders, count) => {
|
|
200
|
+
await client.session.promptAsync({
|
|
201
|
+
path: { id: sessionID },
|
|
202
|
+
body: {
|
|
203
|
+
parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
await client.tui.showToast({
|
|
208
|
+
body: {
|
|
209
|
+
message: `\u{1F4AC} chat: ${count} unread from ${senders.join(", ")}`,
|
|
210
|
+
variant: "info",
|
|
211
|
+
duration: 5000,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// TUI may not be connected. Best-effort.
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
canDeliver: canPromptSession,
|
|
219
|
+
});
|
|
220
|
+
// With chat disabled the tools refuse, so the poller would only heartbeat
|
|
221
|
+
// sessions and burn wake budget against a feature the user turned off.
|
|
222
|
+
if (chatOn) chatPoller.start();
|
|
223
|
+
|
|
143
224
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
144
225
|
// a session is in this set - the agent can't call tools during summary
|
|
145
226
|
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
@@ -211,7 +292,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
211
292
|
console.error(`[thatch] skill install failed: ${err}`);
|
|
212
293
|
}
|
|
213
294
|
|
|
214
|
-
const sys = systemPrompt(repo);
|
|
295
|
+
const sys = systemPrompt(repo, chatOn);
|
|
215
296
|
const compact = compactionContext(repo);
|
|
216
297
|
|
|
217
298
|
// Direct extraction: create a child session linked to the parent and prompt
|
|
@@ -300,6 +381,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
300
381
|
return extraction.buildPayload(all, repo);
|
|
301
382
|
},
|
|
302
383
|
watcherRegistry: watchers,
|
|
384
|
+
projectDir: worktree,
|
|
303
385
|
}),
|
|
304
386
|
|
|
305
387
|
// 1. System prompt - always in context.
|
|
@@ -407,6 +489,40 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
407
489
|
childMetrics.set(input.sessionID, metrics);
|
|
408
490
|
return;
|
|
409
491
|
}
|
|
492
|
+
// Chat conversational events echo back into the transcript as visible
|
|
493
|
+
// message parts. Plugin tools render as muted one-line entries with
|
|
494
|
+
// the output hidden behind a default-off TUI toggle, so without this
|
|
495
|
+
// the human watching a session never sees the conversation happen.
|
|
496
|
+
// Delivery is a non-synthetic noReply promptAsync: the part renders
|
|
497
|
+
// as a visible bubble and starts no model turn (the server's prompt
|
|
498
|
+
// path returns before the completion loop). Non-synthetic also means
|
|
499
|
+
// later turns see the echo in context - a small duplication of the
|
|
500
|
+
// tool call it mirrors, accepted for visibility. Fire-and-forget: an
|
|
501
|
+
// echo failure must never fail the tool call it follows.
|
|
502
|
+
if (input.tool.startsWith("thatch_chat_")) {
|
|
503
|
+
const echo = chatEchoText(
|
|
504
|
+
input.tool,
|
|
505
|
+
(input.args ?? {}) as Record<string, unknown>,
|
|
506
|
+
typeof output.output === "string" ? output.output : "",
|
|
507
|
+
);
|
|
508
|
+
if (echo) {
|
|
509
|
+
void client.session
|
|
510
|
+
.promptAsync({
|
|
511
|
+
path: { id: input.sessionID },
|
|
512
|
+
body: {
|
|
513
|
+
noReply: true,
|
|
514
|
+
parts: [{ type: "text", text: echo }],
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
.catch((err) => {
|
|
518
|
+
// The echo must never fail the tool call, but a silent catch
|
|
519
|
+
// would hide a systemic promptAsync failure (bubbles would
|
|
520
|
+
// stop appearing everywhere with no diagnostics).
|
|
521
|
+
console.error(`[thatch] chat echo delivery failed: ${err}`);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
410
526
|
if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
|
|
411
527
|
extraction.push({
|
|
412
528
|
tool: input.tool,
|
|
@@ -434,6 +550,12 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
434
550
|
// a summary, so a nudge that says "use thatch_memory_recall" triggers a
|
|
435
551
|
// blocked-tool error.
|
|
436
552
|
"chat.message": async (input, output) => {
|
|
553
|
+
// Chat transcript echoes are non-synthetic noReply parts, so they
|
|
554
|
+
// arrive here like any user message - but no model turn ever reads
|
|
555
|
+
// them, and the nudge machinery would embed and scan their text for
|
|
556
|
+
// nothing (and fire spurious toasts when thresholds cross). Skip
|
|
557
|
+
// them entirely.
|
|
558
|
+
if (isChatEchoParts(output.parts)) return;
|
|
437
559
|
if (compacting.has(input.sessionID)) {
|
|
438
560
|
// The session is marked as compacting. If this message is the
|
|
439
561
|
// compaction summary generation itself (has a compaction-type part),
|
|
@@ -732,6 +854,28 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
732
854
|
}
|
|
733
855
|
return;
|
|
734
856
|
}
|
|
857
|
+
// Auto-registration: every top-level opencode session joins the
|
|
858
|
+
// chat directory on its first idle event (unless the user turned
|
|
859
|
+
// auto-registration off). The name is assigned, never chosen - a
|
|
860
|
+
// slug of the session title plus a never-reused counter - and the
|
|
861
|
+
// live title rides the topic column, refreshed on every idle so the
|
|
862
|
+
// roster tracks what the session is actually working on. Titles are
|
|
863
|
+
// usually placeholders at first idle and real within a turn or two,
|
|
864
|
+
// so both name and topic converge fast. Fire-and-forget: a failed
|
|
865
|
+
// registration or title fetch must never block event delivery.
|
|
866
|
+
if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
|
|
867
|
+
void (async () => {
|
|
868
|
+
try {
|
|
869
|
+
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
870
|
+
const title = data?.title ?? "";
|
|
871
|
+
const base = title && !isDefaultSessionTitle(title) ? slugifyTitle(title) : null;
|
|
872
|
+
db.registerChatSession(sessionID, repo, base ? title : null, "opencode", base);
|
|
873
|
+
if (base) db.refreshChatTopic(sessionID, title);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
|
|
876
|
+
}
|
|
877
|
+
})();
|
|
878
|
+
}
|
|
735
879
|
// Parent went idle - trigger direct extraction if there are pending
|
|
736
880
|
// tool interactions and no extraction is already running. Falls
|
|
737
881
|
// back to the nudge path on the next chat.message if this throws.
|
|
@@ -757,10 +901,29 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
757
901
|
} catch (err) {
|
|
758
902
|
console.error(`[thatch] watcher delivery on idle failed: ${err}`);
|
|
759
903
|
}
|
|
904
|
+
// Same for chat messages: mail that arrived mid-turn lands now
|
|
905
|
+
// instead of waiting for the next poll cycle.
|
|
906
|
+
try {
|
|
907
|
+
await chatPoller.deliverPending();
|
|
908
|
+
} catch (err) {
|
|
909
|
+
console.error(`[thatch] chat delivery on idle failed: ${err}`);
|
|
910
|
+
}
|
|
760
911
|
return;
|
|
761
912
|
}
|
|
762
913
|
if (event.type === "session.deleted") {
|
|
763
914
|
const id = event.properties.info.id;
|
|
915
|
+
// Shrink the chat poller's hosted set FIRST: the rest of this
|
|
916
|
+
// branch runs extraction calls that could throw on a transient DB
|
|
917
|
+
// error, and a leaked status key would keep heartbeat-ing a dead
|
|
918
|
+
// session as permanently fresh.
|
|
919
|
+
sessionStatus.delete(id);
|
|
920
|
+
// Tombstone the auto-registerer: an idle IIFE already in flight
|
|
921
|
+
// (title fetch pending) would otherwise re-insert a directory row
|
|
922
|
+
// for a session the user just deleted - a week-long roster zombie
|
|
923
|
+
// whose host is gone. The unregister below tombstones (see
|
|
924
|
+
// ChatStore.unregister), and the tombstone is cleared only by an
|
|
925
|
+
// explicit chat_register rejoin, so a genuinely new session is
|
|
926
|
+
// unaffected.
|
|
764
927
|
// A child deleted before completing never processed its payload.
|
|
765
928
|
const parentID = childToParent.get(id);
|
|
766
929
|
if (parentID) {
|
|
@@ -776,8 +939,15 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
776
939
|
// notifications no longer exists.
|
|
777
940
|
extraction.completeAccepted(id);
|
|
778
941
|
extracting.delete(id);
|
|
779
|
-
sessionStatus.delete(id);
|
|
780
942
|
watchers.cancelSession(id);
|
|
943
|
+
// Leaving the chat directory is the graceful-exit fast path; a
|
|
944
|
+
// crashed process never fires session.deleted, so the heartbeat
|
|
945
|
+
// staleness in chat_list is the covering signal for that case.
|
|
946
|
+
try {
|
|
947
|
+
db.unregisterChatSession(id);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
console.error(`[thatch] chat unregister on delete failed: ${err}`);
|
|
950
|
+
}
|
|
781
951
|
return;
|
|
782
952
|
}
|
|
783
953
|
|
|
@@ -816,6 +986,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
816
986
|
dispose: async () => {
|
|
817
987
|
stopVersionChecker();
|
|
818
988
|
watchers.dispose();
|
|
989
|
+
chatPoller.dispose();
|
|
819
990
|
// Release native ONNX sessions while the worker is still healthy. Left
|
|
820
991
|
// to Bun's teardown, their NAPI finalizers panic the process (see
|
|
821
992
|
// BgeEmbeddingModel.dispose).
|