@jeffober/thatch 0.1.48 → 0.2.1
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 +43 -4
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/bin/thatch +31 -16
- package/docs/dev/README.md +39 -13
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +12 -3
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +16 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +6 -3
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +94 -4
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +8 -4
- package/docs/user/memory.md +1 -1
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +80 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +157 -12
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +210 -0
- package/src/index.ts +77 -1339
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +605 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +181 -31
- package/src/runtime.ts +2004 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +340 -46
- package/src/tools.ts +16 -35
- package/src/watchers.ts +109 -14
package/src/index.ts
CHANGED
|
@@ -1,1350 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
16
|
+
|
|
17
|
+
import { appendFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
4
19
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
}
|
|
99
|
-
|
|
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
|
-
}
|
|
116
|
-
|
|
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
|
-
}
|
|
131
|
-
|
|
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() };
|
|
137
|
-
},
|
|
138
|
-
};
|
|
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
|
-
}
|
|
20
|
+
import type { Plugin as V2Plugin } from "@opencode/plugin";
|
|
21
|
+
|
|
22
|
+
// Pure, SDK-free helpers - tests, the CLI, and QA use cases import these.
|
|
23
|
+
export {
|
|
24
|
+
osProcessArgs,
|
|
25
|
+
startupSessionId,
|
|
26
|
+
startupSessionIdFromArgv,
|
|
27
|
+
continuesLastSessionFromArgv,
|
|
28
|
+
continuesLastSessionId,
|
|
29
|
+
} from "./os-args";
|
|
30
|
+
export { hygieneReport } from "./hygiene";
|
|
164
31
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
32
|
+
// One-per-process unhandledRejection logger, opt-in via THATCH_DEBUG. The
|
|
33
|
+
// host logs these bare ("unhandled rejection" + message, no stack), which is
|
|
34
|
+
// exactly wrong for diagnosing which async continuation in the plugin
|
|
35
|
+
// escaped its error handling - the dispose-race class in particular escapes
|
|
36
|
+
// through several surfaces (pollers, hooks, delayed finalizers). Writes to
|
|
37
|
+
// the debug log beside the database (same file createDebugLog uses), tagged
|
|
38
|
+
// so a grep on "[rejection:" pulls the story out.
|
|
39
|
+
//
|
|
40
|
+
// Registered at module scope because rejections can fire before or after
|
|
41
|
+
// setup runs, and once per process because v2 re-evaluates this module on
|
|
42
|
+
// every plugin reload (a bare process.on would pile up listeners). The key
|
|
43
|
+
// is a Symbol.for so the guard survives module re-evaluation in the same
|
|
44
|
+
// thread; worker threads have their own globalThis and process, and one
|
|
45
|
+
// logger per thread is correct anyway. Registering the listener marks the
|
|
46
|
+
// rejections "handled" - suppressing the host's default crash-on-unhandled -
|
|
47
|
+
// so the handler also echoes the detail to console.error to keep the
|
|
48
|
+
// failure visible while diagnosing.
|
|
49
|
+
const rejectionLoggerKey = Symbol.for("thatch.unhandledRejectionLogger");
|
|
50
|
+
if (process.env.THATCH_DEBUG && !(globalThis as any)[rejectionLoggerKey]) {
|
|
173
51
|
const home = process.env.HOME ?? "/tmp";
|
|
174
|
-
const configHome = process.env.XDG_CONFIG_HOME ??
|
|
175
|
-
const dbPath = process.env.THATCH_DB_PATH ??
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 () => {
|
|
52
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
|
|
53
|
+
const dbPath = process.env.THATCH_DB_PATH ?? `${configHome}/thatch/thatch.db`;
|
|
54
|
+
// A file: URI db path has no meaningful sibling debug.log (appendFileSync
|
|
55
|
+
// cannot open "file:.../debug.log"); skip logging rather than silently
|
|
56
|
+
// drop every rejection into the catch below.
|
|
57
|
+
(globalThis as any)[rejectionLoggerKey] = !dbPath.startsWith("file:");
|
|
58
|
+
if ((globalThis as any)[rejectionLoggerKey]) {
|
|
59
|
+
const file = join(dirname(dbPath), "debug.log");
|
|
60
|
+
process.on("unhandledRejection", (reason) => {
|
|
61
|
+
const detail =
|
|
62
|
+
reason instanceof Error
|
|
63
|
+
? `${reason.message}\n${reason.stack ?? "(no stack)"}`
|
|
64
|
+
: String(reason);
|
|
65
|
+
console.error(`[thatch] unhandled rejection: ${detail.split("\n")[0]}`);
|
|
370
66
|
try {
|
|
371
|
-
|
|
372
|
-
const title = data?.title ?? "";
|
|
373
|
-
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
374
|
-
if (topic) db.refreshChatTopic(sessionID, topic);
|
|
67
|
+
appendFileSync(file, `${new Date().toISOString()} [rejection:unhandled] ${detail}\n`);
|
|
375
68
|
} catch {
|
|
376
|
-
//
|
|
377
|
-
// session's first idle either way.
|
|
69
|
+
// Diagnostics must not break the host (same contract as debug.log).
|
|
378
70
|
}
|
|
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
71
|
});
|
|
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
72
|
}
|
|
73
|
+
}
|
|
589
74
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
},
|
|
75
|
+
// Named v1 export, preserved for the shim and tests. Lazy wrapper: never
|
|
76
|
+
// evaluate the v1 adapter (and its SDK import) under a v2 host.
|
|
77
|
+
export const server: Plugin = async (input, options) => (await import("./opencode/v1")).server(input, options);
|
|
1336
78
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
},
|
|
1347
|
-
};
|
|
79
|
+
export default {
|
|
80
|
+
id: "jeffober-thatch",
|
|
81
|
+
setup: async (context: unknown) => {
|
|
82
|
+
const mod = await import("./opencode/v2");
|
|
83
|
+
return mod.setup(context as V2Plugin.Context);
|
|
84
|
+
},
|
|
85
|
+
// The same lazy wrapper as the named export above: v1 hosts invoke it via
|
|
86
|
+
// default.server, tests and shims via the named export.
|
|
87
|
+
server,
|
|
1348
88
|
};
|
|
1349
|
-
|
|
1350
|
-
export { hygieneReport } from "./hygiene";
|