@jeffober/thatch 0.1.48 → 0.2.0

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 (46) hide show
  1. package/README.md +43 -4
  2. package/bin/thatch +31 -16
  3. package/docs/dev/README.md +28 -6
  4. package/docs/dev/features/behavior-engine.md +3 -2
  5. package/docs/dev/features/cicd.md +1 -1
  6. package/docs/dev/features/compaction-recovery.md +1 -1
  7. package/docs/dev/features/cross-session-chat.md +4 -2
  8. package/docs/dev/features/extraction.md +10 -2
  9. package/docs/dev/features/hygiene.md +3 -1
  10. package/docs/dev/features/multi-host.md +4 -2
  11. package/docs/dev/features/notifications.md +121 -4
  12. package/docs/dev/features/nudge-pipeline.md +4 -2
  13. package/docs/dev/features/opencode-plugin.md +133 -0
  14. package/docs/dev/features/qa-system.md +43 -0
  15. package/docs/dev/features/repo-identity.md +1 -1
  16. package/docs/dev/features/session-lifecycle.md +5 -2
  17. package/docs/dev/features/watchers.md +12 -5
  18. package/docs/dev/gotchas.md +36 -3
  19. package/docs/dev/mcp-parity.md +4 -3
  20. package/docs/dev/setup-and-hooks.md +6 -1
  21. package/docs/plans/llm-alerts.md +287 -0
  22. package/docs/plans/prediction-consolidation.md +3 -3
  23. package/docs/user/README.md +7 -3
  24. package/docs/user/notifications.md +63 -4
  25. package/docs/user/setup.md +4 -0
  26. package/docs/user/watchers.md +15 -7
  27. package/package.json +3 -1
  28. package/src/alerts.ts +360 -0
  29. package/src/capabilities.ts +174 -0
  30. package/src/chat.ts +63 -5
  31. package/src/commands.ts +44 -5
  32. package/src/config.ts +32 -1
  33. package/src/db.ts +80 -0
  34. package/src/embeddings.ts +43 -0
  35. package/src/extraction.ts +124 -0
  36. package/src/index.ts +38 -1345
  37. package/src/opencode/v1.ts +54 -0
  38. package/src/opencode/v2.ts +561 -0
  39. package/src/os-args.ts +99 -0
  40. package/src/prompts.ts +77 -1
  41. package/src/runtime.ts +1815 -0
  42. package/src/seed-behaviors.ts +1 -1
  43. package/src/session-db.ts +32 -0
  44. package/src/tool-defs.ts +185 -19
  45. package/src/tools.ts +16 -35
  46. package/src/watchers.ts +95 -14
package/src/index.ts CHANGED
@@ -1,1350 +1,43 @@
1
- import { join } from "node:path";
2
- import { readFileSync } from "node:fs";
3
- import { createDebugLog } from "./debug";
4
- import type { Plugin } from "@opencode-ai/plugin";
5
- import { ThatchDB, repoPathCache } from "./db";
6
- import { BgeEmbeddingModel } from "./embeddings";
7
- import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
8
- import { createTools } from "./tools";
9
- import {
10
- systemPrompt,
11
- compactionContext,
12
- sessionStartReminder,
13
- recallNudge,
14
- extractionNudge,
15
- extractionDirectPrompt,
16
- predictionNudge,
17
- behaviorNudge,
18
- versionWarningNudge,
19
- type NudgeMatch,
20
- } from "./prompts";
21
- import { ExtractionPipeline, type ToolInteraction } from "./extraction";
22
- import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
23
- import { installOpencodeCommands, COMPACT_READY_TOKEN, EXIT_READY_TOKEN } from "./commands";
24
- import { hygieneReport } from "./hygiene";
25
- import { seedDefaultBehaviors } from "./seed-behaviors";
26
- import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
27
- import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, withCwdFallback } from "./watchers";
28
- import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
29
- import { ChatPoller, createWakeGate, isDefaultSessionTitle } from "./chat";
30
- import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
31
- import pkg from "../package.json";
32
-
33
- // ---------------------------------------------------------------------------
34
- // V1 server export - tools, prompt injection, session hooks
35
- // ---------------------------------------------------------------------------
36
-
37
- // Minimum cosine score for the prompt-aware recall nudge. Lower than
38
- // findDuplicates' 0.85 (near-dupes) because "relates to" is a weaker signal
39
- // than "duplicate." Tunable via THATCH_RECALL_THRESHOLD.
40
- const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55");
41
-
42
- // Prompts shorter than this skip the recall nudge - trivially short prompts
43
- // like "yes" or "ok" match too broadly to be useful.
44
- const MIN_PROMPT_LEN = 10;
45
-
46
- // Minimum cosine score for the prediction auto-fire. Higher than the
47
- // recall nudge (0.55) because surfacing a user-preference nudge is more
48
- // disruptive than surfacing a memory -- the agent may act on it or
49
- // surface it to the user. 0.60 cuts noise in dense embedding spaces
50
- // while still catching genuinely related contexts.
51
- const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
52
-
53
- // Same threshold for behavior auto-fire. Same rationale: surfacing a
54
- // self-discipline rule is disruptive and should only fire when the
55
- // situation genuinely matches.
56
- const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
57
-
58
- // Wrap-up command registry: command name as opencode derives it from the
59
- // installed file path (~/.config/opencode/command/thatch/<name>.md), the
60
- // greenlight token the command instructs the model to end its response with,
61
- // and which TUI action the greenlight triggers.
62
- const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit" }> = {
63
- "thatch/compact": { token: COMPACT_READY_TOKEN, kind: "compact" },
64
- "thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
65
- };
66
-
67
- /**
68
- * Parses the session id a harness was started to continue from an argument
69
- * list (`opencode -s <id>` / `--session <id>` / `--session=<id>`). Pure:
70
- * the caller supplies the list (see startupSessionId for where it comes
71
- * from). The framework passes no session id to plugins and resuming a
72
- * session fires no events, so the command line is the only signal that a
73
- * fresh harness is the new home of a continued session.
74
- */
75
- export function startupSessionIdFromArgv(argv: string[]): string | null {
76
- for (let i = 0; i < argv.length; i++) {
77
- const arg = argv[i];
78
- if (arg === "-s" || arg === "--session") return argv[i + 1] ?? null;
79
- if (arg.startsWith("--session=")) return arg.slice("--session=".length) || null;
80
- }
81
- return null;
82
- }
83
-
84
- /**
85
- * Whether the harness was started with `-c`/`--continue` (continue the most
86
- * recent session). Pure. Yargs-style boolean flags only: `--continue=false`
87
- * reads as the negation, everything else as a plain switch. The session id
88
- * itself is not on the command line - the harness resolves "most recent
89
- * top-level session" at startup, which the plugin replicates via the SDK
90
- * (continuesLastSessionId).
91
- */
92
- export function continuesLastSessionFromArgv(argv: string[]): boolean {
93
- for (const arg of argv) {
94
- if (arg === "-c" || arg === "--continue") return true;
95
- if (arg.startsWith("--continue=")) return arg.slice("--continue=".length) !== "false";
96
- }
97
- return false;
98
- }
1
+ // Dual-shape plugin entry: opencode v1 (1.18.x) and v2 (2.x) load this same
2
+ // module but read different keys. The v1 loader (readV1Plugin) reads
3
+ // `default.server`; the v2 validator decodes `default` as `{ id, setup }`
4
+ // and strips excess keys. A merged default export carrying all three loads
5
+ // under either host, so no runtime version detection is needed and a single
6
+ // shim file serves both.
7
+ //
8
+ // ISOLATION RULE: this module's RUNTIME import graph must stay SDK-free
9
+ // (type-only imports are fine - they erase). Each adapter imports its host's
10
+ // SDK (@opencode-ai/plugin vs @opencode/plugin), and the SDKs resolve only
11
+ // under their own host's install, so both adapters are reached exclusively
12
+ // through dynamic import below. The same rule applies to src/runtime.ts: it
13
+ // is reachable from both adapters, so it too must never runtime-import an
14
+ // SDK. The pure helpers re-exported here live in SDK-free modules for the
15
+ // same reason.
99
16
 
100
- /**
101
- * Picks the session `opencode -c` continues: the most recently updated
102
- * top-level session. Pure; the caller supplies the session list from
103
- * client.session.list() (directory-scoped, like the plugin's own client).
104
- * The server's list order is not trusted - the TUI re-sorts by
105
- * time.updated before picking, and so does this.
106
- */
107
- export function continuesLastSessionId(
108
- sessions: { id: string; parentID?: string; time?: { updated?: number } }[],
109
- ): string | null {
110
- return (
111
- [...sessions]
112
- .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
113
- .find((s) => !s.parentID)?.id ?? null
114
- );
115
- }
17
+ import type { Plugin } from "@opencode-ai/plugin";
18
+ import type { Plugin as V2Plugin } from "@opencode/plugin";
19
+
20
+ // Pure, SDK-free helpers - tests, the CLI, and QA use cases import these.
21
+ export {
22
+ osProcessArgs,
23
+ startupSessionId,
24
+ startupSessionIdFromArgv,
25
+ continuesLastSessionFromArgv,
26
+ continuesLastSessionId,
27
+ } from "./os-args";
28
+ export { hygieneReport } from "./hygiene";
116
29
 
117
- // The plugin does not run in the process's main thread: opencode's TUI
118
- // spawns a worker thread (cli/tui/worker.js) to host the server, and worker
119
- // threads get their own argv (just the worker script) with none of the CLI
120
- // flags. The thread still shares the process's pid, so the OS-level command
121
- // line for our own pid is the real `opencode -s ses_...` invocation. Read it
122
- // from /proc on Linux or via ps on macOS. Returns [] when unavailable;
123
- // callers treat that as "no session flag". The readers are injectable so
124
- // tests can drive each branch without a real /proc or ps.
125
- export interface OsArgsDeps {
126
- /** Reads a file as UTF-8, throwing when it does not exist. */
127
- readFile: (path: string) => string;
128
- /** Runs `ps -p <pid> -o args=`; returns its exit code and stdout. */
129
- ps: (pid: number) => { exitCode: number; stdout: string };
130
- }
30
+ // Named v1 export, preserved for the shim and tests. Lazy wrapper: never
31
+ // evaluate the v1 adapter (and its SDK import) under a v2 host.
32
+ export const server: Plugin = async (input, options) => (await import("./opencode/v1")).server(input, options);
131
33
 
132
- const defaultOsArgsDeps: OsArgsDeps = {
133
- readFile: (path) => readFileSync(path, "utf8"),
134
- ps: (pid) => {
135
- const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "args="]);
136
- return { exitCode: res.exitCode, stdout: res.stdout.toString() };
34
+ export default {
35
+ id: "jeffober-thatch",
36
+ setup: async (context: unknown) => {
37
+ const mod = await import("./opencode/v2");
38
+ return mod.setup(context as V2Plugin.Context);
137
39
  },
40
+ // The same lazy wrapper as the named export above: v1 hosts invoke it via
41
+ // default.server, tests and shims via the named export.
42
+ server,
138
43
  };
139
-
140
- export function osProcessArgs(deps: OsArgsDeps = defaultOsArgsDeps, pid = process.pid): string[] {
141
- try {
142
- // Linux: cmdline is NUL-separated argv, exact and cheap.
143
- return deps.readFile("/proc/self/cmdline").split("\0").filter(Boolean);
144
- } catch {
145
- // macOS has no /proc; ask ps for this pid's full command. Splitting on
146
- // whitespace is safe for our purpose: session ids never contain spaces.
147
- try {
148
- const res = deps.ps(pid);
149
- if (res.exitCode !== 0) return [];
150
- return res.stdout.trim().split(/\s+/).filter(Boolean);
151
- } catch {
152
- return [];
153
- }
154
- }
155
- }
156
-
157
- // Resolve the session id this harness was launched to resume, if any:
158
- // prefer the thread-local argv (covers tests and any non-worker host), then
159
- // fall back to the process's OS-level command line (the real harness path).
160
- // The caller passes the OS command line in so it is read once per init.
161
- export function startupSessionId(osArgs: string[]): string | null {
162
- return startupSessionIdFromArgv(process.argv) ?? startupSessionIdFromArgv(osArgs);
163
- }
164
-
165
- export const server: Plugin = async ({ client, worktree, directory }) => {
166
- // The opencode server's cwd is wherever the server happened to start;
167
- // `worktree` is the project this plugin instance actually serves.
168
- // `directory` is the session's own directory - on resume after the
169
- // worktree was deleted, opencode reassigns worktree to "/" (it walks up
170
- // from the missing dir, finds no .git, and boots the global project) but
171
- // still forwards the original path as directory, which is the only input
172
- // the repo_paths cache can recover identity from.
173
- const home = process.env.HOME ?? "/tmp";
174
- const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
175
- const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
176
- const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
177
-
178
- // The DB must exist before identity detection: detectRepo consults the
179
- // repo_paths cache when the session directory is gone.
180
- const db = new ThatchDB(dbPath);
181
- const repoCache = repoPathCache(db);
182
- const repo = await detectRepo(directory ?? worktree, repoCache);
183
-
184
- // Whether cross-session chat is on (chat.enabled, default on). Read once
185
- // at init: it gates the chat poller and the prompt's chat section. The
186
- // chat tools re-read the file per call, so a toggle takes effect there
187
- // without a restart.
188
- const chatOn = chatEnabled(loadConfig(dbPath).config);
189
-
190
- const debug = createDebugLog(dbPath);
191
- const model = new BgeEmbeddingModel(modelName);
192
- const extraction = new ExtractionPipeline();
193
-
194
- // Seed default behaviors into the global store on first run, and
195
- // update them when their content changes across releases. Idempotent:
196
- // behaviors are matched by key stamp, not by cosine similarity, so
197
- // user-codified behaviors are never overwritten. See seed-behaviors.ts
198
- // for the full mechanism.
199
- await seedDefaultBehaviors(db, model);
200
-
201
- // Start background npm update polling. The checker caches the latest
202
- // version in memory and to a file. Never blocks the plugin or tool calls.
203
- // The opencode plugin path has no hook/server split, so version skew
204
- // between hooks and server is not a concern here. But two opencode
205
- // sessions on different versions sharing one DB can cause schema/data
206
- // corruption. The npm update nudge covers the "please update" case.
207
- // The on-disk version check (readOnDiskVersion) catches the case where
208
- // the user ran `npm update` but hasn't restarted opencode yet.
209
- startVersionChecker(dbPath);
210
-
211
- // The running version, frozen at module load time. Compared against the
212
- // on-disk package.json version on each chat.message to detect post-upgrade
213
- // non-restart.
214
- const runningVersion = pkg.version;
215
-
216
- // Latest observed opencode session status per session ("busy" | "idle" |
217
- // "retry"). Keys enter from session.status events and leave only in
218
- // session.deleted (which shrinks the chat poller's hosted set). Readers:
219
- // the watcher registry's and the chat poller's canDeliver gates
220
- // (proactive prompts only fire into idle sessions, never into a running
221
- // turn), and the chat poller's hostedSessions set (the sessions this
222
- // process may deliver chat mail to - the map's keys).
223
- const sessionStatus = new Map<string, string>();
224
-
225
- // The proactive-prompt gate, shared by both delivery registries (watchers
226
- // and chat). Two layers, in cost order: the compacting set and the
227
- // event-fed sessionStatus map answer cheaply, and a final check verifies
228
- // against the server's live status - the map is only as fresh as the
229
- // event stream, and a stale "idle" turns a wake into mid-turn context
230
- // injection (observed live: a session read mail mid-burst, contradicted
231
- // its own in-flight plan, and looped).
232
- const canPromptSession = createWakeGate({
233
- isCompacting: (sessionID) => compacting.has(sessionID),
234
- mappedStatus: (sessionID) => sessionStatus.get(sessionID),
235
- fetchStatuses: async () => {
236
- const { data } = await client.session.status();
237
- return data ?? {};
238
- },
239
- onStatusError: (sessionID, err) => {
240
- console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
241
- },
242
- });
243
-
244
- // In-memory watcher registry for proactive event notifications (GitHub PR
245
- // and branch watching, plus local command watches). Deliberately
246
- // process-scoped: no SQLite, no cross-restart state. See src/watchers.ts
247
- // for the rationale.
248
- //
249
- // Delivery prompts the session with a synthetic part - the same mechanism
250
- // opencode uses for background task completions - so a watched event
251
- // triggers a model turn even when the user is away. Events carry pointer
252
- // data plus machine status (check conclusions, exit codes); the model
253
- // fetches logs and further details itself with gh.
254
- // Watched-command runner: resolves the spawn cwd per poll. When the
255
- // project directory is deleted mid-watch (worktree merged and cleaned up),
256
- // the poll falls back to the repo_paths cache's main checkout instead of
257
- // spinning ENOENT errors until the TTL. The wrapper logs the fallback
258
- // once per dead directory - per-cycle error logging is the existing
259
- // failure mode and it spams.
260
- const watchedCommandRunner = withCwdFallback(runWatchedCommand, (cwd) => resolveSpawnCwd(cwd, repoCache), (from, to) => {
261
- console.error(`[thatch] project directory ${from} deleted; watcher falling back to main checkout ${to}`);
262
- });
263
-
264
- const watchers = new WatcherRegistry({
265
- deliver: async (sessionID, events) => {
266
- // Events carry their watch's target label from the registry, so the
267
- // notification header is correct for every source (PRs, branches,
268
- // commands, CI events whose URLs would not parse).
269
- await client.session.promptAsync({
270
- path: { id: sessionID },
271
- body: {
272
- parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true }],
273
- },
274
- });
275
- // Toast: the notification part is TUI-hidden, so without this the
276
- // user watching the session sees the model wake up with no visible
277
- // cause. Announce what fired. Failed checks/workflows surface as a
278
- // warning variant; routine events as info. Best-effort - the TUI may
279
- // not be connected (headless mode).
280
- const failed = events.some((e) => e.summary.includes("failure"));
281
- const more = events.length > 1 ? ` +${events.length - 1} more` : "";
282
- try {
283
- await client.tui.showToast({
284
- body: {
285
- message: `\u23F0 ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
286
- variant: failed ? "warning" : "info",
287
- duration: 5000,
288
- },
289
- });
290
- } catch {
291
- // TUI may not be connected. Best-effort.
292
- }
293
- },
294
- canDeliver: canPromptSession,
295
- ghRunner: ghApiRun,
296
- commandRunner: watchedCommandRunner,
297
- });
298
- // gh presence decides whether watch_create and watch_branch_create work;
299
- // checked lazily by the tools, but log once at startup so misconfiguration
300
- // is visible in debug logs. watch_command_create needs only bash, not gh.
301
- void ghAvailable().then((ok) => {
302
- if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
303
- });
304
- watchers.start();
305
-
306
- // The session this harness was launched to resume, resolved at startup:
307
- // `-s <id>` names it on the command line, `-c` means "most recent in this
308
- // directory" and resolves through the SDK. Either way the poller hosts it
309
- // from the first beat, so the heartbeat and delivery are live before the
310
- // user's first prompt.
311
- let resumedSession: string | null = null;
312
-
313
- // Chat names assigned by the startup resume paths (-s and -c) wait here
314
- // until the session's first message or idle event, which toast them. At
315
- // plugin-init time the TUI is not yet connected, so an immediate toast
316
- // would be dropped silently.
317
- const chatStartupNames = new Map<string, string>();
318
-
319
- const chatPoller = new ChatPoller({
320
- store: db,
321
- hostedSessions: () => {
322
- // Hosting = the sessions this harness serves: those seen as events
323
- // here (used in this TUI) plus the resumed startup session. Sub-agent
324
- // children are excluded even if someone registered one - a
325
- // registered child would be heartbeat-ed fresh-forever and burn
326
- // nudge budget on undeliverable wake prompts.
327
- const hosted = [...sessionStatus.keys()];
328
- if (resumedSession) hosted.push(resumedSession);
329
- return hosted.filter((id) => !childToParent.has(id));
330
- },
331
- deliver: async (sessionID, senders, count) => {
332
- await client.session.promptAsync({
333
- path: { id: sessionID },
334
- body: {
335
- parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
336
- },
337
- });
338
- try {
339
- await client.tui.showToast({
340
- body: {
341
- message: `\u{1F4AC} chat: ${count} unread from ${senders.join(", ")}`,
342
- variant: "info",
343
- duration: 5000,
344
- },
345
- });
346
- } catch {
347
- // TUI may not be connected. Best-effort.
348
- }
349
- },
350
- canDeliver: canPromptSession,
351
- });
352
- // With chat disabled the tools refuse, so the poller would only heartbeat
353
- // sessions and burn wake budget against a feature the user turned off.
354
- if (chatOn) chatPoller.start();
355
-
356
- // Startup registration: `opencode -s <id>` continues a session whose
357
- // directory row may predate this process (its harness died, or the
358
- // machine restarted); `opencode -c` continues the most recent session in
359
- // this directory. The framework passes no session id to plugins and
360
- // resuming fires no events, so the command line is the source (see
361
- // startupSessionId). Registering here RECLAIMS the row - the name is
362
- // owned by the session id and never changes - and refreshes its
363
- // heartbeat: synchronously for -s (local SQLite, it cannot fail on a
364
- // server that is still starting); for -c after an SDK list call resolves
365
- // the target. The async tail (title fetch, asleep-mail delivery) is
366
- // best-effort - the poller retries whatever it misses, and the first
367
- // idle converges the topic.
368
- const reclaimTail = (sessionID: string) => {
369
- void (async () => {
370
- try {
371
- const { data } = await client.session.get({ path: { id: sessionID } });
372
- const title = data?.title ?? "";
373
- const topic = title && !isDefaultSessionTitle(title) ? title : null;
374
- if (topic) db.refreshChatTopic(sessionID, topic);
375
- } catch {
376
- // Server may still be starting; the topic converges on the
377
- // session's first idle either way.
378
- }
379
- try {
380
- // Asleep-mail: deliver what queued while this session was away,
381
- // through the normal path (gate, nudge, toast, stamps).
382
- await chatPoller.deliverPending();
383
- } catch {
384
- // The poller cycle retries delivery; nothing to do here.
385
- }
386
- })();
387
- };
388
- const osArgs = osProcessArgs();
389
- const startupSession = startupSessionId(osArgs);
390
- const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
391
- const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
392
- const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
393
- debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} continue=${continueLast} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
394
- if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
395
- resumedSession = startupSession;
396
- const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
397
- debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
398
- if (res.ok) {
399
- chatStartupNames.set(startupSession, res.name);
400
- reclaimTail(startupSession);
401
- }
402
- } else if (chatOn && autoRegisterOn && continueLast) {
403
- void (async () => {
404
- try {
405
- const { data } = await client.session.list();
406
- const target = continuesLastSessionId(data ?? []);
407
- debug("chat:startup", `-c resolved: ${target ?? "none"}`);
408
- if (!target || db.hasChatLeaveTombstone(target)) return;
409
- resumedSession = target;
410
- const res = db.registerChatSession(target, repo, null, "opencode", null, detectWorktreeKind(worktree));
411
- debug("chat:startup", `registration for ${target}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
412
- if (res.ok) {
413
- chatStartupNames.set(target, res.name);
414
- reclaimTail(target);
415
- }
416
- } catch (err) {
417
- console.error(`[thatch] chat -c startup registration failed: ${err}`);
418
- }
419
- })();
420
- }
421
-
422
- // Sessions currently being compacted. chat.message nudges are skipped while
423
- // a session is in this set - the agent can't call tools during summary
424
- // generation, so a recall or extraction nudge would cause a blocked-tool
425
- // error. Cleared by experimental.compaction.autocontinue (success), the
426
- // session.compacted event (redundant belt-and-suspenders), or chat.message
427
- // itself when a non-compaction message arrives (compaction failure fallback
428
- // - autocontinue never fired, so the next real user message clears the
429
- // stale flag). If all three somehow miss, the flag leaks (graceful
430
- // degradation: nudges stay off for that session, but no crash).
431
- const compacting = new Set<string>();
432
-
433
- // Per-session count of consecutive extraction nudges delivered without any
434
- // memory_remember call in between. Drives nudge escalation: the agent gets
435
- // a couple of polite chances, then the tone shifts to directive, then to
436
- // all-caps shouting. Reset to 0 whenever the agent writes a memory.
437
- const missedNudges = new Map<string, number>();
438
-
439
- // Parent-child session mapping for cross-session buffer drain. opencode
440
- // creates real child sessions with their own IDs for sub-agents; without
441
- // this map, a memory_remember call in a child (e.g. a background
442
- // fact-extractor task) drains the child's empty buffer but leaves the
443
- // parent's buffer untouched, causing the nudge to replay indefinitely.
444
- // Populated from session.created events that carry a parentID.
445
- const childToParent = new Map<string, string>();
446
-
447
- // Snapshot of the parent's buffer at the moment each child was dispatched.
448
- // When the child writes a memory, consumeSnapshot drains only these entries
449
- // (by reference identity), preserving interleaved-turn entries that arrived
450
- // while the sub-agent was running. Without this, the child's memory_remember
451
- // would drain the parent's ENTIRE buffer - silently dropping facts from
452
- // any tool calls the parent made concurrently with the sub-agent.
453
- const parentSnapshots = new Map<string, ToolInteraction[]>();
454
-
455
- // Parent sessions with an active direct-extraction child. When the parent
456
- // goes idle with pending tool interactions, the plugin creates a child
457
- // session and prompts it directly (via the SDK client) instead of injecting
458
- // a nudge into the next user message. This set suppresses the nudge path
459
- // while the child runs, and prevents re-triggering if the parent goes idle
460
- // again before the child finishes. Cleared when the child goes idle, errors,
461
- // or is deleted. If direct extraction fails, the set is cleared so the nudge
462
- // path takes over as a fallback on the next chat.message.
463
- const extracting = new Set<string>();
464
-
465
- // Child session IDs created by triggerExtraction (direct extraction only).
466
- // The child idle handler uses this to distinguish plugin-created extraction
467
- // children from task-dispatched sub-agents (code review specialists, the
468
- // nudge-path fact-extractor, any model-dispatched task). Only extraction
469
- // children get the full cleanup: buffer drain, session deletion, toast.
470
- // Non-extraction children get the old behavior (completeAccepted +
471
- // missedNudges.reset) so their sessions are not deleted out from under the
472
- // task tool that dispatched them.
473
- const extractionChildren = new Set<string>();
474
-
475
- // Per-child-session extraction metrics for the toast notification. When the
476
- // child session goes idle, these counts are formatted into a toast that
477
- // shows the user thatch is working without polluting the conversation.
478
- // Keyed by child session ID. Cleaned up on child idle, error, or deletion.
479
- const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
480
-
481
- // Wrap-up slash commands (/thatch/compact, /thatch/exit) awaiting their
482
- // greenlight check. command.execute.before marks the session when one of
483
- // these commands runs; when the session next goes idle, the plugin reads
484
- // the final assistant message for the ready token (the greenlight) and
485
- // triggers the TUI action. Token absence means the model listed blockers
486
- // instead, so nothing fires beyond a toast. Cleared on resolution or on
487
- // session deletion.
488
- const pendingWrapUp = new Map<string, { token: string; kind: "compact" | "exit" }>();
489
-
490
- // Skills always install to the global opencode config - installing into the
491
- // worktree would mutate the user's repo (untracked files in git status).
492
- // A failed install degrades the nudge workflow but must not kill the plugin.
493
- try {
494
- installSkills(join(configHome, "opencode", "skills"), [
495
- ...SHARED_SKILLS,
496
- ...OPENCODE_ONLY_SKILLS,
497
- ]);
498
- } catch (err) {
499
- console.error(`[thatch] skill install failed: ${err}`);
500
- }
501
-
502
- // Same for the wrap-up slash commands: synced into the global config dir on
503
- // every load so template updates self-heal. Config is loaded before plugins
504
- // during server startup, so a first-ever install lands on the next start.
505
- try {
506
- installOpencodeCommands(configHome);
507
- } catch (err) {
508
- console.error(`[thatch] command install failed: ${err}`);
509
- }
510
-
511
- const sys = systemPrompt(repo, chatOn);
512
- const compact = compactionContext(repo);
513
-
514
- // Direct extraction: create a child session linked to the parent and prompt
515
- // it with the extraction payload. The child runs the fact-extractor skill,
516
- // writes memories, and goes idle. The existing childToParent / snapshot
517
- // drain machinery handles buffer cleanup. The nudge path is a fallback if
518
- // this throws.
519
- //
520
- // Does NOT call extraction.accept - entries stay in pending so consumeSnapshot
521
- // can drain them by reference identity when the child writes a memory. The
522
- // extracting set (not accept) suppresses the nudge in chat.message.
523
- //
524
- // The child ID is added to extractionChildren so the idle handler can
525
- // distinguish plugin-created extraction children from task-dispatched
526
- // sub-agents. Only extraction children get the full cleanup (buffer drain,
527
- // session deletion, toast).
528
- async function triggerExtraction(parentID: string): Promise<void> {
529
- extracting.add(parentID);
530
-
531
- const batch = extraction.peek(parentID);
532
- const promptText = extractionDirectPrompt(batch.length, parentID);
533
-
534
- const result = await client.session.create({
535
- body: { parentID, title: "thatch-extraction" },
536
- });
537
- // session.created event fires here, setting childToParent and
538
- // parentSnapshots (snapshot of the full pending buffer, since we
539
- // have not called accept).
540
- const childId = result.data!.id;
541
- extractionChildren.add(childId);
542
-
543
- // Clean up the child session and all map entries if prompting fails.
544
- // Without this, the child exists on the server but was never prompted,
545
- // so it never goes idle and the maps leak.
546
- const cleanupChild = () => {
547
- extracting.delete(parentID);
548
- extractionChildren.delete(childId);
549
- childToParent.delete(childId);
550
- parentSnapshots.delete(childId);
551
- childMetrics.delete(childId);
552
- try { client.session.delete({ path: { id: childId } }); } catch {}
553
- };
554
-
555
- const bgEnabled =
556
- process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === "true" ||
557
- process.env.OPENCODE_EXPERIMENTAL === "true";
558
-
559
- if (bgEnabled) {
560
- try {
561
- await client.session.promptAsync({
562
- path: { id: childId },
563
- body: {
564
- parts: [{ type: "text", text: promptText }],
565
- },
566
- });
567
- } catch (err) {
568
- console.error(`[thatch] extraction promptAsync failed: ${err}`);
569
- cleanupChild();
570
- throw err;
571
- }
572
- } else {
573
- // Fire and forget - the parent is already idle, so blocking the event
574
- // handler would only delay other event processing. The child runs to
575
- // completion and its idle event triggers cleanup.
576
- client.session
577
- .prompt({
578
- path: { id: childId },
579
- body: {
580
- parts: [{ type: "text", text: promptText }],
581
- },
582
- })
583
- .catch((err: unknown) => {
584
- console.error(`[thatch] extraction child failed: ${err}`);
585
- cleanupChild();
586
- });
587
- }
588
- }
589
-
590
- return {
591
- tool: createTools(db, model, repo, {
592
- extractionPayloadProvider: (sessionID: string): string | null => {
593
- const interactions = extraction.peek(sessionID);
594
- const accepted = extraction.peekAccepted(sessionID);
595
- const all = [...accepted, ...interactions];
596
- if (all.length === 0) return null;
597
- return extraction.buildPayload(all, repo);
598
- },
599
- watcherRegistry: watchers,
600
- projectDir: worktree,
601
- }),
602
-
603
- // 1. System prompt - always in context.
604
- "experimental.chat.system.transform": async (_input, output) => {
605
- output.system.push(sys);
606
- },
607
-
608
- // 2. Compaction context - re-familiarizes after compaction. The flag
609
- // suppresses chat.message nudges during summary generation (tool
610
- // calls are blocked there).
611
- "experimental.session.compacting": async (input, output) => {
612
- compacting.add(input.sessionID);
613
- output.context.push(compact);
614
- },
615
-
616
- // 2b. Clear the compacting flag after compaction succeeds so chat.message
617
- // nudges resume for the synthetic auto-continue turn and beyond.
618
- "experimental.compaction.autocontinue": async (input) => {
619
- compacting.delete(input.sessionID);
620
- },
621
-
622
- // 3. Tool buffering - feeds the extraction pipeline (direct extraction
623
- // via SDK or nudge fallback). Excluded tools:
624
- // - thatch_*: extracting facts from memory ops would echo the store
625
- // - skill/task: meta-tools that orchestrate agent behavior (loading
626
- // skills, dispatching sub-agents). Buffering them creates a feedback
627
- // loop - extraction triggers a skill load, which gets buffered, which
628
- // triggers another extraction on the next turn.
629
- // Memory writes consume the buffer and reset the missed-nudge counter.
630
- // The buffer is NOT drained on nudge delivery - it persists until the
631
- // agent writes a memory, so ignored nudges accumulate and the payload
632
- // grows with each missed cycle.
633
- //
634
- // Buffer lifecycle is AMQP-style accept/complete, so a failed
635
- // extractor does not silently drop unprocessed interactions:
636
- // - thatch_extraction_done in the parent ACCEPTS the buffer: it moves
637
- // to a holding area and the nudge quiets, but entries are not
638
- // dropped yet.
639
- // - COMPLETE drops the held entries. Signals: a memory_remember or
640
- // extraction_done call in a child session of that parent (the
641
- // extractor confirming it processed them), or the child going idle
642
- // (a no-save run that never acks still counts as processed).
643
- // - REQUEUE returns the held entries to pending. Signals: the child
644
- // session errors or is deleted before completing.
645
- //
646
- // A memory_remember call in a child session (sub-agent) also drains
647
- // the parent's pending buffer - but only the entries that existed at
648
- // dispatch time (the snapshot). Entries from interleaved turns survive
649
- // so their facts aren't silently dropped.
650
- "tool.execute.after": async (input, output) => {
651
- if (input.tool === "thatch_memory_remember") {
652
- extraction.consume(input.sessionID);
653
- missedNudges.delete(input.sessionID);
654
- // Track extraction metrics for toast display. Counted for any
655
- // child session (both direct-extraction and nudge-path sub-agents)
656
- // since childToParent covers both. The toast only fires for
657
- // extraction children (extractionChildren set) on idle.
658
- const parentID = childToParent.get(input.sessionID);
659
- if (parentID) {
660
- const metrics = childMetrics.get(input.sessionID) ?? { new: 0, updated: 0, deleted: 0 };
661
- if (input.args?.overwrite) metrics.updated++;
662
- else metrics.new++;
663
- childMetrics.set(input.sessionID, metrics);
664
- // Complete the parent's accepted entries (the extractor confirmed
665
- // it is alive and saving) and drain the parent's snapshot entries
666
- // from the pending buffer. If no snapshot was recorded (unreachable
667
- // when childToParent has the entry, since both are set together in
668
- // session.created), skip the drain rather than dropping the entire
669
- // buffer - interleaved-turn entries that arrived while the child
670
- // was running must survive for the next extraction cycle.
671
- extraction.completeAccepted(parentID);
672
- const snapshot = parentSnapshots.get(input.sessionID);
673
- if (snapshot) {
674
- extraction.consumeSnapshot(parentID, snapshot);
675
- parentSnapshots.delete(input.sessionID);
676
- }
677
- missedNudges.delete(parentID);
678
- }
679
- return;
680
- }
681
- // thatch_extraction_done has two roles depending on the session:
682
- // - parent, after dispatching the fact-extractor: ACCEPT the buffer
683
- // (quiet the nudge, hold the entries for completion).
684
- // - child extractor, at the end of its run: COMPLETE the parent's
685
- // accepted entries, including no-save runs that write no memory,
686
- // and drop the child's own buffer (its work is done).
687
- if (input.tool === "thatch_extraction_done") {
688
- const parentID = childToParent.get(input.sessionID);
689
- if (parentID) {
690
- extraction.completeAccepted(parentID);
691
- missedNudges.delete(parentID);
692
- extraction.consume(input.sessionID);
693
- } else {
694
- extraction.accept(input.sessionID);
695
- }
696
- missedNudges.delete(input.sessionID);
697
- return;
698
- }
699
- // Track memory deletions in child sessions for the toast metrics.
700
- // Same scoping as the remember handler above - only child sessions,
701
- // not the parent or manual memory writes from the user's session.
702
- if (input.tool === "thatch_memory_forget" && childToParent.has(input.sessionID)) {
703
- const metrics = childMetrics.get(input.sessionID) ?? { new: 0, updated: 0, deleted: 0 };
704
- metrics.deleted++;
705
- childMetrics.set(input.sessionID, metrics);
706
- return;
707
- }
708
- // Chat conversational events echo back into the transcript as visible
709
- // message parts. Plugin tools render as muted one-line entries with
710
- // the output hidden behind a default-off TUI toggle, so without this
711
- // the human watching a session never sees the conversation happen.
712
- // Delivery is a non-synthetic noReply promptAsync: the part renders
713
- // as a visible bubble and starts no model turn (the server's prompt
714
- // path returns before the completion loop). Non-synthetic also means
715
- // later turns see the echo in context - a small duplication of the
716
- // tool call it mirrors, accepted for visibility. Fire-and-forget: an
717
- // echo failure must never fail the tool call it follows.
718
- if (input.tool.startsWith("thatch_chat_")) {
719
- const echo = chatEchoText(
720
- input.tool,
721
- (input.args ?? {}) as Record<string, unknown>,
722
- typeof output.output === "string" ? output.output : "",
723
- );
724
- if (echo) {
725
- void client.session
726
- .promptAsync({
727
- path: { id: input.sessionID },
728
- body: {
729
- noReply: true,
730
- parts: [{ type: "text", text: echo }],
731
- },
732
- })
733
- .catch((err) => {
734
- // The echo must never fail the tool call, but a silent catch
735
- // would hide a systemic promptAsync failure (bubbles would
736
- // stop appearing everywhere with no diagnostics).
737
- console.error(`[thatch] chat echo delivery failed: ${err}`);
738
- });
739
- }
740
- return;
741
- }
742
- if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
743
- extraction.push({
744
- tool: input.tool,
745
- sessionID: input.sessionID,
746
- args: input.args ?? {},
747
- title: output.title,
748
- output: typeof output.output === "string" ? output.output : "",
749
- });
750
- },
751
-
752
- // 4. Per-message nudge - two priority tiers:
753
- // a. Extraction nudge: prior tool interactions are queued for fact
754
- // extraction (carries the JSON payload for thatch-fact-extractor).
755
- // b. Recall + prediction + behavior nudge: when no extraction is pending, embed
756
- // the user's prompt (shared embedding for all three) and:
757
- // - search memories by cosine (recall nudge)
758
- // - search matchers by cosine, score predictions (prediction fire)
759
- // - search behavior matchers by cosine, score behaviors (behavior fire)
760
- // All three fire independently; any subset may inject.
761
- // The auto-fires reuse the embedding already computed for recall,
762
- // adding only cosine scans against the matchers tables.
763
- // No extra model calls.
764
- //
765
- // Skipped during compaction: the agent can't call tools while generating
766
- // a summary, so a nudge that says "use thatch_memory_recall" triggers a
767
- // blocked-tool error.
768
- // Wrap-up commands mark their session here; the actual trigger happens
769
- // on the session's next idle event, once the model's greenlight response
770
- // is complete.
771
- "command.execute.before": async (input) => {
772
- const wrapUp = WRAPUP_COMMANDS[input.command];
773
- if (wrapUp) pendingWrapUp.set(input.sessionID, wrapUp);
774
- },
775
-
776
- "chat.message": async (input, output) => {
777
- // Chat transcript echoes are non-synthetic noReply parts, so they
778
- // arrive here like any user message - but no model turn ever reads
779
- // them, and the nudge machinery would embed and scan their text for
780
- // nothing (and fire spurious toasts when thresholds cross). Skip
781
- // them entirely.
782
- if (isChatEchoParts(output.parts)) return;
783
-
784
- // Prompt-path registration: a brand-new session joins the chat
785
- // directory the moment its first real user message arrives - before
786
- // the model runs - so it is addressable within that first turn (the
787
- // first-idle path alone leaves a turn-long dark window). Local
788
- // SQLite, so it runs inline; on an already-registered session this
789
- // is a free mid-turn heartbeat touch. Sub-agent children are excluded
790
- // (they register only by mistake, and their deletion tombstones the
791
- // row), as are sessions that left via chat_unregister. The idle path
792
- // below stays as the backstop and converges the topic.
793
- if (
794
- chatOn &&
795
- chatAutoRegister(loadConfig(dbPath).config) &&
796
- input.sessionID &&
797
- !childToParent.has(input.sessionID) &&
798
- !db.hasChatLeaveTombstone(input.sessionID)
799
- ) {
800
- try {
801
- const res = db.registerChatSession(input.sessionID, repo, null, "opencode", null, detectWorktreeKind(worktree));
802
- // First registration - or a startup resume reclaiming its name,
803
- // announced here because the TUI was not connected at init - is
804
- // the one moment the user should notice: a quiet toast, not a
805
- // conversation message.
806
- if (res.ok && (res.created || chatStartupNames.has(input.sessionID))) {
807
- chatStartupNames.delete(input.sessionID);
808
- try {
809
- await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
810
- } catch {
811
- // Headless or disconnected TUI - registration stands.
812
- }
813
- }
814
- } catch (err) {
815
- console.error(`[thatch] chat prompt-register failed for ${input.sessionID}: ${err}`);
816
- }
817
- }
818
-
819
- if (compacting.has(input.sessionID)) {
820
- // The session is marked as compacting. If this message is the
821
- // compaction summary generation itself (has a compaction-type part),
822
- // suppress nudges - tools are blocked during summary generation and a
823
- // nudge would cause a blocked-tool error. If it is NOT a compaction
824
- // message, compaction has already failed (autocontinue never fired)
825
- // and the user is sending a new message. Clear the stale flag and
826
- // proceed normally - tools are available again.
827
- const isCompactionMsg = (output.parts as any[]).some((p) => p.type === "compaction");
828
- if (isCompactionMsg) return;
829
- compacting.delete(input.sessionID);
830
- }
831
-
832
- // Version warning: check if the on-disk package.json version differs
833
- // from the running version (user upgraded but didn't restart opencode),
834
- // or if the npm poller found a newer version. Best-effort: any failure
835
- // silently skips. Injected as a synthetic part so the LLM sees it and
836
- // can tell the user to restart.
837
- try {
838
- const onDisk = readOnDiskVersion();
839
- const checker = getVersionChecker();
840
- const npmUpdate = checker?.getUpdateWarning() ?? null;
841
- const skewWarning = onDisk && compareSemver(runningVersion, onDisk) < 0
842
- ? `thatch was upgraded to v${onDisk} but this session is running v${runningVersion}. Restart opencode to apply the update.`
843
- : null;
844
- const warning = skewWarning ?? npmUpdate;
845
- if (warning) {
846
- output.parts.push({
847
- id: `prt_thatch_ver_${Math.random().toString(36).slice(2)}`,
848
- sessionID: input.sessionID,
849
- messageID: input.messageID ?? output.message.id,
850
- type: "text",
851
- text: versionWarningNudge(warning),
852
- synthetic: true,
853
- });
854
- try {
855
- const short = skewWarning
856
- ? `\u26A0\uFE0F thatch upgraded to v${onDisk} (running v${runningVersion})`
857
- : `\u26A0\uFE0F thatch v${checker?.getLatestVersion()} is available`;
858
- await client.tui.showToast({
859
- body: {
860
- message: short,
861
- variant: "warning",
862
- duration: 5000,
863
- },
864
- });
865
- } catch {
866
- // TUI may not be connected. Best-effort.
867
- }
868
- }
869
- } catch {
870
- // Best-effort. Version check failure must not block nudges.
871
- }
872
-
873
- // Extraction nudge (fallback path). Skipped when the extracting set
874
- // is active - that means a direct-extraction child session is running
875
- // and the plugin is handling extraction via the SDK. The nudge fires
876
- // here only when direct extraction was never triggered or threw.
877
- if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
878
- const batch = extraction.peek(input.sessionID);
879
- const missed = missedNudges.get(input.sessionID) ?? 0;
880
- const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
881
- missedNudges.set(input.sessionID, missed + 1);
882
-
883
- output.parts.push({
884
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
885
- sessionID: input.sessionID,
886
- messageID: input.messageID ?? output.message.id,
887
- type: "text",
888
- text,
889
- synthetic: true,
890
- });
891
- return;
892
- }
893
-
894
- // No extraction pending - try the prompt-aware recall nudge. Extract
895
- // the user's prompt text from the message parts, embed it with the
896
- // warm in-process model, and search for matches. Best-effort: any
897
- // failure (no text, model not loaded, empty store) silently skips.
898
- try {
899
- const promptText = (output.parts as any[])
900
- .filter((p) => p.type === "text" && !p.synthetic)
901
- .map((p) => p.text)
902
- .join(" ");
903
- if (promptText.length < MIN_PROMPT_LEN) return;
904
-
905
- const embedding = await model.queryEmbed(promptText);
906
-
907
- // Recall nudge: separate try/catch so a memory-search failure
908
- // does not block the prediction fire (they share only the embedding).
909
- try {
910
- const results = db.search([repo, "global"], embedding, { limit: 5 });
911
- const matches: NudgeMatch[] = results
912
- .filter((r) => r._score >= RECALL_THRESHOLD)
913
- .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
914
-
915
- if (matches.length > 0) {
916
- output.parts.push({
917
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
918
- sessionID: input.sessionID,
919
- messageID: input.messageID ?? output.message.id,
920
- type: "text",
921
- text: recallNudge(matches),
922
- synthetic: true,
923
- });
924
- try {
925
- await client.tui.showToast({
926
- body: {
927
- message: `\u{1F4AD} recalled ${matches.length} memor${matches.length === 1 ? "y" : "ies"}`,
928
- variant: "info",
929
- duration: 3000,
930
- },
931
- });
932
- } catch {
933
- // TUI may not be connected. Best-effort.
934
- }
935
- }
936
- } catch (err) {
937
- console.error(`[thatch] recall nudge failed: ${err}`);
938
- }
939
-
940
- // Prediction fire: independent of recall; a failure here does
941
- // not affect the recall nudge that may have already been pushed.
942
- try {
943
- const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
944
- if (predItems.length > 0) {
945
- output.parts.push({
946
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
947
- sessionID: input.sessionID,
948
- messageID: input.messageID ?? output.message.id,
949
- type: "text",
950
- text: predictionNudge(predItems),
951
- synthetic: true,
952
- });
953
- try {
954
- await client.tui.showToast({
955
- body: {
956
- message: `\u{1F4AD} ${predItems.length} prediction${predItems.length === 1 ? "" : "s"} surfaced`,
957
- variant: "info",
958
- duration: 3000,
959
- },
960
- });
961
- } catch {
962
- // TUI may not be connected. Best-effort.
963
- }
964
- }
965
- } catch (err) {
966
- console.error(`[thatch] prediction nudge failed: ${err}`);
967
- }
968
-
969
- // Behavior fire: independent of recall and prediction. Same
970
- // embedding, one more cosine scan against behavior_matchers.
971
- try {
972
- const behaviorItems = db.scoreBehaviorNudge([repo, "global"], embedding, BEHAVIOR_THRESHOLD);
973
- if (behaviorItems.length > 0) {
974
- output.parts.push({
975
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
976
- sessionID: input.sessionID,
977
- messageID: input.messageID ?? output.message.id,
978
- type: "text",
979
- text: behaviorNudge(behaviorItems),
980
- synthetic: true,
981
- });
982
- try {
983
- await client.tui.showToast({
984
- body: {
985
- message: `\u{1F4AD} ${behaviorItems.length} behavior${behaviorItems.length === 1 ? "" : "s"} surfaced`,
986
- variant: "info",
987
- duration: 3000,
988
- },
989
- });
990
- } catch {
991
- // TUI may not be connected. Best-effort.
992
- }
993
- }
994
- } catch (err) {
995
- console.error(`[thatch] behavior nudge failed: ${err}`);
996
- }
997
- } catch (err) {
998
- console.error(`[thatch] nudge hook failed: ${err}`);
999
- }
1000
- },
1001
-
1002
- // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
1003
- // best-effort: a failure there must not cost the reminder itself.
1004
- //
1005
- // Also tracks parent-child session relationships for cross-session buffer
1006
- // drain. session.created with a parentID records the mapping AND
1007
- // snapshots the parent's current buffer so the child's later
1008
- // memory_remember drains only those snapshot entries, not the parent's
1009
- // entire buffer (which may have grown from interleaved turns).
1010
- //
1011
- // Child lifecycle events: idle means the extractor finished; error or
1012
- // deletion before completion means the facts were never extracted, so
1013
- // entries go back to pending. Only extraction children (created by
1014
- // triggerExtraction, tracked in extractionChildren) get the full cleanup
1015
- // (buffer drain, session deletion, toast). Task-dispatched sub-agents
1016
- // get the old behavior (completeAccepted + missedNudges.reset) so their
1017
- // sessions are not deleted out from under the task tool.
1018
- event: async ({ event }) => {
1019
- if (event.type === "session.created") {
1020
- const info = event.properties.info;
1021
- if (info.parentID) {
1022
- childToParent.set(info.id, info.parentID);
1023
- parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
1024
- // Child sessions don't need the session-start reminder - only
1025
- // top-level sessions do. Returning here prevents extraction
1026
- // children from receiving the hygiene report and tool overview.
1027
- return;
1028
- }
1029
- }
1030
- if (event.type === "session.error") {
1031
- const childID = event.properties.sessionID;
1032
- const parentID = childID ? childToParent.get(childID) : undefined;
1033
- if (parentID && childID) {
1034
- extraction.requeueAccepted(parentID);
1035
- extracting.delete(parentID);
1036
- childToParent.delete(childID);
1037
- parentSnapshots.delete(childID);
1038
- childMetrics.delete(childID);
1039
- extractionChildren.delete(childID);
1040
- }
1041
- return;
1042
- }
1043
- if (event.type === "session.status") {
1044
- const sessionID = event.properties.sessionID;
1045
- const statusType = event.properties.status?.type;
1046
- // Record the latest status so the watcher registry can gate
1047
- // proactive prompt delivery on idle sessions.
1048
- if (sessionID && statusType) sessionStatus.set(sessionID, statusType);
1049
- if (statusType !== "idle") return;
1050
- const parentID = sessionID ? childToParent.get(sessionID) : undefined;
1051
- if (parentID && sessionID) {
1052
- // A child session went idle. Two cases:
1053
- // - Extraction child (created by triggerExtraction, tracked in
1054
- // extractionChildren): drain the parent's snapshot, fire a toast
1055
- // with metrics, delete the child session, clean up all maps.
1056
- // - Task-dispatched sub-agent (code review specialist, nudge-path
1057
- // fact-extractor, any model-dispatched task): complete the
1058
- // parent's accepted entries and reset missedNudges. Do NOT drain
1059
- // the buffer or delete the session - the task tool that
1060
- // dispatched the sub-agent needs to read its output.
1061
- if (extractionChildren.has(sessionID)) {
1062
- // Drain the parent's snapshot entries from the pending buffer.
1063
- // If the child wrote memories, consumeSnapshot already ran in
1064
- // tool.execute.after and the snapshot is gone - nothing to
1065
- // drain. If the child did a no-save run, the snapshot entries
1066
- // are still in the buffer and need to be drained here so they
1067
- // don't replay as a nudge on the next chat.message. Never drain
1068
- // the entire buffer - interleaved-turn entries must survive.
1069
- const snapshot = parentSnapshots.get(sessionID);
1070
- if (snapshot) {
1071
- extraction.consumeSnapshot(parentID, snapshot);
1072
- }
1073
-
1074
- // Fire a toast with the extraction metrics. Only show a toast
1075
- // when memories were actually written - no toast for no-save
1076
- // runs to avoid notification fatigue.
1077
- const metrics = childMetrics.get(sessionID);
1078
- const parts: string[] = [];
1079
- if (metrics) {
1080
- if (metrics.new > 0) parts.push(`new: ${metrics.new}`);
1081
- if (metrics.updated > 0) parts.push(`updated: ${metrics.updated}`);
1082
- if (metrics.deleted > 0) parts.push(`deleted: ${metrics.deleted}`);
1083
- }
1084
- if (parts.length > 0) {
1085
- try {
1086
- await client.tui.showToast({
1087
- body: { message: `\u{1F4AD} ${parts.join(", ")}`, variant: "success", duration: 4000 },
1088
- });
1089
- } catch {
1090
- // TUI may not be connected (e.g. headless mode).
1091
- }
1092
- }
1093
-
1094
- extraction.completeAccepted(parentID);
1095
- missedNudges.delete(parentID);
1096
- extracting.delete(parentID);
1097
- childToParent.delete(sessionID);
1098
- parentSnapshots.delete(sessionID);
1099
- childMetrics.delete(sessionID);
1100
- extractionChildren.delete(sessionID);
1101
- extraction.consume(sessionID);
1102
- // Delete the child session to avoid clutter.
1103
- try {
1104
- await client.session.delete({ path: { id: sessionID } });
1105
- } catch {
1106
- // Best-effort - the child is idle and harmless if not deleted.
1107
- }
1108
- } else {
1109
- // Task-dispatched sub-agent went idle. Complete the parent's
1110
- // accepted entries (from the nudge-path extraction_done accept)
1111
- // and reset missedNudges. Retained for the nudge fallback path.
1112
- extraction.completeAccepted(parentID);
1113
- missedNudges.delete(parentID);
1114
- }
1115
- return;
1116
- }
1117
- // Wrap-up command resolution: the session went idle right after
1118
- // /thatch/compact or /thatch/exit. The final assistant message's
1119
- // trailing token is the greenlight - the command instructs the model
1120
- // to end with it only after flushing persistence and confirming no
1121
- // loose ends. Token absence means the model listed blockers, so the
1122
- // action never fires; a toast points the user at the blockers above.
1123
- const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
1124
- if (wrapUp) {
1125
- pendingWrapUp.delete(sessionID);
1126
- let ready = false;
1127
- try {
1128
- const { data } = await client.session.messages({ path: { id: sessionID } });
1129
- const last = [...(data ?? [])].reverse().find((m) => m.info.role === "assistant");
1130
- const text = (last?.parts ?? [])
1131
- .filter((p) => p.type === "text")
1132
- .map((p) => p.text ?? "")
1133
- .join("\n")
1134
- .trimEnd();
1135
- ready = text.endsWith(wrapUp.token);
1136
- } catch (err) {
1137
- console.error(`[thatch] wrap-up message fetch failed: ${err}`);
1138
- }
1139
- if (ready) {
1140
- // An exit-greenlit session is leaving the process: leave the
1141
- // chat directory too, so other sessions stop addressing mail to
1142
- // a roster entry whose host is about to vanish. The unregister
1143
- // tombstone also stops any straggler auto-register from
1144
- // resurrecting the row during shutdown. Compact keeps the
1145
- // session alive, so only the exit path does this.
1146
- if (wrapUp.kind === "exit") {
1147
- try {
1148
- db.unregisterChatSession(sessionID);
1149
- } catch (err) {
1150
- console.error(`[thatch] chat unregister on exit failed: ${err}`);
1151
- }
1152
- }
1153
- try {
1154
- if (wrapUp.kind === "compact") {
1155
- // executeCommand only accepts legacy alias names;
1156
- // "session_compact" maps to the TUI's session.compact action,
1157
- // the same thing the built-in /compact command runs.
1158
- await client.tui.executeCommand({ body: { command: "session_compact" } });
1159
- } else {
1160
- // No exit alias exists, so publish the TUI keymap command
1161
- // directly - the same dispatch as the /exit slash command.
1162
- await client.tui.publish({
1163
- body: { type: "tui.command.execute", properties: { command: "app.exit" } },
1164
- });
1165
- }
1166
- } catch (err) {
1167
- console.error(`[thatch] wrap-up action failed: ${err}`);
1168
- }
1169
- // Skip extraction and pending deliveries: compaction is starting
1170
- // (the checklist drained the buffer) or the process is exiting.
1171
- return;
1172
- }
1173
- try {
1174
- await client.tui.showToast({
1175
- body: {
1176
- message:
1177
- wrapUp.kind === "compact"
1178
- ? "\u23F8 Not compacting - resolve the items listed above, then run /thatch/compact again"
1179
- : "\u23F8 Not exiting - resolve the items listed above, then run /thatch/exit again",
1180
- variant: "warning",
1181
- duration: 8000,
1182
- },
1183
- });
1184
- } catch {
1185
- // TUI may not be connected (e.g. headless mode).
1186
- }
1187
- // Blocked: fall through so extraction and pending deliveries still
1188
- // run - the model may have buffered tool interactions to flush.
1189
- }
1190
- // Auto-registration: every top-level opencode session joins the
1191
- // chat directory on its first idle event (unless the user turned
1192
- // auto-registration off). The name is assigned, never chosen - a
1193
- // pool draw plus a never-reused counter, kept short because it
1194
- // appears in wake prompts and rosters. The live session title rides
1195
- // the topic column instead (refreshed on every idle), so the name
1196
- // and the description stay separate identities. Fire-and-forget: a
1197
- // failed registration or title fetch must never block event
1198
- // delivery.
1199
- if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
1200
- void (async () => {
1201
- try {
1202
- const { data } = await client.session.get({ path: { id: sessionID } });
1203
- const title = data?.title ?? "";
1204
- // Placeholder titles never become topics (the real one
1205
- // converges on a later idle via refreshChatTopic).
1206
- const topic = title && !isDefaultSessionTitle(title) ? title : null;
1207
- const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
1208
- // The topic converges as the auto-titler lands a real title:
1209
- // registration is once-per-session, so later idles must
1210
- // refresh it explicitly.
1211
- if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
1212
- // First registration - or a startup resume reclaiming its
1213
- // name, announced here because the TUI was not connected at
1214
- // init - is the one moment the user should notice: a quiet
1215
- // toast, not a conversation message.
1216
- if (res.ok && (res.created || chatStartupNames.has(sessionID))) {
1217
- chatStartupNames.delete(sessionID);
1218
- try {
1219
- await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
1220
- } catch {
1221
- // Headless or disconnected TUI - registration stands.
1222
- }
1223
- }
1224
- } catch (err) {
1225
- console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
1226
- }
1227
- })();
1228
- }
1229
- // Parent went idle - trigger direct extraction if there are pending
1230
- // tool interactions and no extraction is already running. Falls
1231
- // back to the nudge path on the next chat.message if this throws.
1232
- if (
1233
- sessionID &&
1234
- !compacting.has(sessionID) &&
1235
- !extracting.has(sessionID) &&
1236
- extraction.pending(sessionID)
1237
- ) {
1238
- try {
1239
- await triggerExtraction(sessionID);
1240
- } catch (err) {
1241
- console.error(`[thatch] direct extraction trigger failed: ${err}`);
1242
- extracting.delete(sessionID);
1243
- }
1244
- }
1245
- // The session just became idle, so watcher events queued while it
1246
- // was busy can be delivered now - the poll interval may otherwise
1247
- // hold them for up to a full cycle. Best-effort; failures stay
1248
- // pending and retry on the next poll.
1249
- try {
1250
- await watchers.deliverPending();
1251
- } catch (err) {
1252
- console.error(`[thatch] watcher delivery on idle failed: ${err}`);
1253
- }
1254
- // Same for chat messages: mail that arrived mid-turn lands now
1255
- // instead of waiting for the next poll cycle.
1256
- try {
1257
- await chatPoller.deliverPending();
1258
- } catch (err) {
1259
- console.error(`[thatch] chat delivery on idle failed: ${err}`);
1260
- }
1261
- return;
1262
- }
1263
- if (event.type === "session.deleted") {
1264
- const id = event.properties.info.id;
1265
- // Shrink the chat poller's hosted set FIRST: the rest of this
1266
- // branch runs extraction calls that could throw on a transient DB
1267
- // error, and a leaked status key would keep heartbeat-ing a dead
1268
- // session as permanently fresh.
1269
- sessionStatus.delete(id);
1270
- // Tombstone the auto-registerer: an idle IIFE already in flight
1271
- // (title fetch pending) would otherwise re-insert a directory row
1272
- // for a session the user just deleted - a week-long roster zombie
1273
- // whose host is gone. The unregister below tombstones (see
1274
- // ChatStore.unregister), and the tombstone is cleared only by an
1275
- // explicit chat_register rejoin, so a genuinely new session is
1276
- // unaffected.
1277
- // A child deleted before completing never processed its payload.
1278
- const parentID = childToParent.get(id);
1279
- if (parentID) {
1280
- extraction.requeueAccepted(parentID);
1281
- extracting.delete(parentID);
1282
- }
1283
- childToParent.delete(id);
1284
- parentSnapshots.delete(id);
1285
- childMetrics.delete(id);
1286
- extractionChildren.delete(id);
1287
- pendingWrapUp.delete(id);
1288
- // A deleted parent takes its accepted entries with it, and its
1289
- // watchers die with it - the session that would receive their
1290
- // notifications no longer exists.
1291
- extraction.completeAccepted(id);
1292
- extracting.delete(id);
1293
- watchers.cancelSession(id);
1294
- // Leaving the chat directory is the graceful-exit fast path; a
1295
- // crashed process never fires session.deleted, so the heartbeat
1296
- // staleness in chat_list is the covering signal for that case.
1297
- try {
1298
- db.unregisterChatSession(id);
1299
- } catch (err) {
1300
- console.error(`[thatch] chat unregister on delete failed: ${err}`);
1301
- }
1302
- return;
1303
- }
1304
-
1305
- // session.compacted fires on successful compaction. Redundant with
1306
- // experimental.compaction.autocontinue, but belt-and-suspenders: if
1307
- // autocontinue didn't fire or wasn't installed, the event still clears
1308
- // the compacting flag so nudges resume.
1309
- if (event.type === "session.compacted") {
1310
- compacting.delete(event.properties.sessionID);
1311
- return;
1312
- }
1313
-
1314
- if (event.type !== "session.created") return;
1315
- const id = event.properties.info.id;
1316
-
1317
- let hygiene: string | null = null;
1318
- try {
1319
- hygiene = await hygieneReport(db, repo, worktree, repoCache);
1320
- } catch (err) {
1321
- console.error(`[thatch] hygiene report failed: ${err}`);
1322
- }
1323
-
1324
- try {
1325
- await client.session.prompt({
1326
- path: { id },
1327
- body: {
1328
- noReply: true,
1329
- parts: [{ type: "text", text: sessionStartReminder(repo, hygiene), synthetic: true }],
1330
- },
1331
- });
1332
- } catch (err) {
1333
- console.error(`[thatch] session-start reminder failed: ${err}`);
1334
- }
1335
- },
1336
-
1337
- dispose: async () => {
1338
- stopVersionChecker();
1339
- watchers.dispose();
1340
- chatPoller.dispose();
1341
- // Release native ONNX sessions while the worker is still healthy. Left
1342
- // to Bun's teardown, their NAPI finalizers panic the process (see
1343
- // BgeEmbeddingModel.dispose).
1344
- await model.dispose();
1345
- db.close();
1346
- },
1347
- };
1348
- };
1349
-
1350
- export { hygieneReport } from "./hygiene";