@jeffober/thatch 0.1.44 → 0.1.45
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 +80 -7
- package/bin/thatch +44 -9
- package/docs/dev/README.md +4 -2
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/commands.md +74 -0
- package/docs/dev/features/cross-session-chat.md +24 -10
- package/docs/dev/features/extraction.md +5 -0
- package/docs/dev/gotchas.md +8 -0
- package/docs/dev/mcp-parity.md +1 -0
- package/docs/dev/setup-and-hooks.md +3 -1
- package/docs/plans/thatch-actions.md +55 -20
- package/docs/user/README.md +5 -1
- package/docs/user/cli.md +12 -3
- package/docs/user/commands.md +50 -0
- package/docs/user/cross-session-chat.md +11 -7
- package/docs/user/setup.md +9 -3
- package/package.json +1 -1
- package/src/chat.ts +32 -4
- package/src/commands.ts +122 -13
- package/src/db.ts +9 -0
- package/src/embeddings.ts +42 -1
- package/src/index.ts +141 -32
- package/src/mcp.ts +62 -3
- package/src/prompts.ts +55 -1
- package/src/setup.ts +9 -0
- package/src/tool-defs.ts +19 -8
package/src/index.ts
CHANGED
|
@@ -70,9 +70,7 @@ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit"
|
|
|
70
70
|
* the caller supplies the list (see startupSessionId for where it comes
|
|
71
71
|
* from). The framework passes no session id to plugins and resuming a
|
|
72
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
|
-
* (continue last) resolves no id here - that session registers on its
|
|
75
|
-
* first idle like any other.
|
|
73
|
+
* fresh harness is the new home of a continued session.
|
|
76
74
|
*/
|
|
77
75
|
export function startupSessionIdFromArgv(argv: string[]): string | null {
|
|
78
76
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -83,6 +81,39 @@ export function startupSessionIdFromArgv(argv: string[]): string | null {
|
|
|
83
81
|
return null;
|
|
84
82
|
}
|
|
85
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
|
+
|
|
86
117
|
// The plugin does not run in the process's main thread: opencode's TUI
|
|
87
118
|
// spawns a worker thread (cli/tui/worker.js) to host the server, and worker
|
|
88
119
|
// threads get their own argv (just the worker script) with none of the CLI
|
|
@@ -254,16 +285,29 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
254
285
|
});
|
|
255
286
|
watchers.start();
|
|
256
287
|
|
|
288
|
+
// The session this harness was launched to resume, resolved at startup:
|
|
289
|
+
// `-s <id>` names it on the command line, `-c` means "most recent in this
|
|
290
|
+
// directory" and resolves through the SDK. Either way the poller hosts it
|
|
291
|
+
// from the first beat, so the heartbeat and delivery are live before the
|
|
292
|
+
// user's first prompt.
|
|
293
|
+
let resumedSession: string | null = null;
|
|
294
|
+
|
|
295
|
+
// Chat names assigned by the startup resume paths (-s and -c) wait here
|
|
296
|
+
// until the session's first message or idle event, which toast them. At
|
|
297
|
+
// plugin-init time the TUI is not yet connected, so an immediate toast
|
|
298
|
+
// would be dropped silently.
|
|
299
|
+
const chatStartupNames = new Map<string, string>();
|
|
300
|
+
|
|
257
301
|
const chatPoller = new ChatPoller({
|
|
258
302
|
store: db,
|
|
259
303
|
hostedSessions: () => {
|
|
260
304
|
// Hosting = the sessions this harness serves: those seen as events
|
|
261
|
-
// here (used in this TUI) plus the
|
|
305
|
+
// here (used in this TUI) plus the resumed startup session. Sub-agent
|
|
262
306
|
// children are excluded even if someone registered one - a
|
|
263
307
|
// registered child would be heartbeat-ed fresh-forever and burn
|
|
264
308
|
// nudge budget on undeliverable wake prompts.
|
|
265
309
|
const hosted = [...sessionStatus.keys()];
|
|
266
|
-
if (
|
|
310
|
+
if (resumedSession) hosted.push(resumedSession);
|
|
267
311
|
return hosted.filter((id) => !childToParent.has(id));
|
|
268
312
|
},
|
|
269
313
|
deliver: async (sessionID, senders, count) => {
|
|
@@ -293,42 +337,68 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
293
337
|
|
|
294
338
|
// Startup registration: `opencode -s <id>` continues a session whose
|
|
295
339
|
// directory row may predate this process (its harness died, or the
|
|
296
|
-
// machine restarted)
|
|
340
|
+
// machine restarted); `opencode -c` continues the most recent session in
|
|
341
|
+
// this directory. The framework passes no session id to plugins and
|
|
297
342
|
// resuming fires no events, so the command line is the source (see
|
|
298
343
|
// startupSessionId). Registering here RECLAIMS the row - the name is
|
|
299
344
|
// owned by the session id and never changes - and refreshes its
|
|
300
|
-
// heartbeat synchronously
|
|
301
|
-
// is still starting
|
|
302
|
-
//
|
|
345
|
+
// heartbeat: synchronously for -s (local SQLite, it cannot fail on a
|
|
346
|
+
// server that is still starting); for -c after an SDK list call resolves
|
|
347
|
+
// the target. The async tail (title fetch, asleep-mail delivery) is
|
|
348
|
+
// best-effort - the poller retries whatever it misses, and the first
|
|
303
349
|
// idle converges the topic.
|
|
350
|
+
const reclaimTail = (sessionID: string) => {
|
|
351
|
+
void (async () => {
|
|
352
|
+
try {
|
|
353
|
+
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
354
|
+
const title = data?.title ?? "";
|
|
355
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
356
|
+
if (topic) db.refreshChatTopic(sessionID, topic);
|
|
357
|
+
} catch {
|
|
358
|
+
// Server may still be starting; the topic converges on the
|
|
359
|
+
// session's first idle either way.
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
// Asleep-mail: deliver what queued while this session was away,
|
|
363
|
+
// through the normal path (gate, nudge, toast, stamps).
|
|
364
|
+
await chatPoller.deliverPending();
|
|
365
|
+
} catch {
|
|
366
|
+
// The poller cycle retries delivery; nothing to do here.
|
|
367
|
+
}
|
|
368
|
+
})();
|
|
369
|
+
};
|
|
304
370
|
const osArgs = osProcessArgs();
|
|
305
371
|
const startupSession = startupSessionId(osArgs);
|
|
372
|
+
const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
|
|
306
373
|
const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
|
|
307
374
|
const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
|
|
308
|
-
debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
|
|
375
|
+
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"}`);
|
|
309
376
|
if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
|
|
377
|
+
resumedSession = startupSession;
|
|
310
378
|
const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
311
379
|
debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
312
380
|
if (res.ok) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const { data } = await client.session.get({ path: { id: startupSession } });
|
|
316
|
-
const title = data?.title ?? "";
|
|
317
|
-
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
318
|
-
if (topic) db.refreshChatTopic(startupSession, topic);
|
|
319
|
-
} catch {
|
|
320
|
-
// Server may still be starting; the topic converges on the
|
|
321
|
-
// session's first idle either way.
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
// Asleep-mail: deliver what queued while this session was away,
|
|
325
|
-
// through the normal path (gate, nudge, toast, stamps).
|
|
326
|
-
await chatPoller.deliverPending();
|
|
327
|
-
} catch {
|
|
328
|
-
// The poller cycle retries delivery; nothing to do here.
|
|
329
|
-
}
|
|
330
|
-
})();
|
|
381
|
+
chatStartupNames.set(startupSession, res.name);
|
|
382
|
+
reclaimTail(startupSession);
|
|
331
383
|
}
|
|
384
|
+
} else if (chatOn && autoRegisterOn && continueLast) {
|
|
385
|
+
void (async () => {
|
|
386
|
+
try {
|
|
387
|
+
const { data } = await client.session.list();
|
|
388
|
+
const target = continuesLastSessionId(data ?? []);
|
|
389
|
+
debug("chat:startup", `-c resolved: ${target ?? "none"}`);
|
|
390
|
+
if (!target || db.hasChatLeaveTombstone(target)) return;
|
|
391
|
+
resumedSession = target;
|
|
392
|
+
const res = db.registerChatSession(target, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
393
|
+
debug("chat:startup", `registration for ${target}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
394
|
+
if (res.ok) {
|
|
395
|
+
chatStartupNames.set(target, res.name);
|
|
396
|
+
reclaimTail(target);
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error(`[thatch] chat -c startup registration failed: ${err}`);
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
332
402
|
}
|
|
333
403
|
|
|
334
404
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
@@ -692,6 +762,42 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
692
762
|
// nothing (and fire spurious toasts when thresholds cross). Skip
|
|
693
763
|
// them entirely.
|
|
694
764
|
if (isChatEchoParts(output.parts)) return;
|
|
765
|
+
|
|
766
|
+
// Prompt-path registration: a brand-new session joins the chat
|
|
767
|
+
// directory the moment its first real user message arrives - before
|
|
768
|
+
// the model runs - so it is addressable within that first turn (the
|
|
769
|
+
// first-idle path alone leaves a turn-long dark window). Local
|
|
770
|
+
// SQLite, so it runs inline; on an already-registered session this
|
|
771
|
+
// is a free mid-turn heartbeat touch. Sub-agent children are excluded
|
|
772
|
+
// (they register only by mistake, and their deletion tombstones the
|
|
773
|
+
// row), as are sessions that left via chat_unregister. The idle path
|
|
774
|
+
// below stays as the backstop and converges the topic.
|
|
775
|
+
if (
|
|
776
|
+
chatOn &&
|
|
777
|
+
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
778
|
+
input.sessionID &&
|
|
779
|
+
!childToParent.has(input.sessionID) &&
|
|
780
|
+
!db.hasChatLeaveTombstone(input.sessionID)
|
|
781
|
+
) {
|
|
782
|
+
try {
|
|
783
|
+
const res = db.registerChatSession(input.sessionID, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
784
|
+
// First registration - or a startup resume reclaiming its name,
|
|
785
|
+
// announced here because the TUI was not connected at init - is
|
|
786
|
+
// the one moment the user should notice: a quiet toast, not a
|
|
787
|
+
// conversation message.
|
|
788
|
+
if (res.ok && (res.created || chatStartupNames.has(input.sessionID))) {
|
|
789
|
+
chatStartupNames.delete(input.sessionID);
|
|
790
|
+
try {
|
|
791
|
+
await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
792
|
+
} catch {
|
|
793
|
+
// Headless or disconnected TUI - registration stands.
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} catch (err) {
|
|
797
|
+
console.error(`[thatch] chat prompt-register failed for ${input.sessionID}: ${err}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
695
801
|
if (compacting.has(input.sessionID)) {
|
|
696
802
|
// The session is marked as compacting. If this message is the
|
|
697
803
|
// compaction summary generation itself (has a compaction-type part),
|
|
@@ -1085,11 +1191,14 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
1085
1191
|
// registration is once-per-session, so later idles must
|
|
1086
1192
|
// refresh it explicitly.
|
|
1087
1193
|
if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1194
|
+
// First registration - or a startup resume reclaiming its
|
|
1195
|
+
// name, announced here because the TUI was not connected at
|
|
1196
|
+
// init - is the one moment the user should notice: a quiet
|
|
1197
|
+
// toast, not a conversation message.
|
|
1198
|
+
if (res.ok && (res.created || chatStartupNames.has(sessionID))) {
|
|
1199
|
+
chatStartupNames.delete(sessionID);
|
|
1091
1200
|
try {
|
|
1092
|
-
await client.tui.showToast({ body: { message: `registered in chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
1201
|
+
await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
1093
1202
|
} catch {
|
|
1094
1203
|
// Headless or disconnected TUI - registration stands.
|
|
1095
1204
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { BgeEmbeddingModel } from "./embeddings";
|
|
|
6
6
|
import { detectRepo } from "./git";
|
|
7
7
|
import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
|
|
8
8
|
import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
|
|
9
|
+
import { actionDefs, type ActionDef } from "./commands";
|
|
10
|
+
import { mcpToolName } from "./prompts";
|
|
9
11
|
import { SidebandServer, sidebandSocketPath } from "./sideband";
|
|
10
12
|
import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
|
|
11
13
|
import { buildExtractionPayload } from "./extraction";
|
|
@@ -44,11 +46,13 @@ const SERVER_NAME = "thatch";
|
|
|
44
46
|
const SERVER_VERSION = pkg.version;
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
|
-
* MCP capabilities declared in the initialize response.
|
|
48
|
-
*
|
|
49
|
+
* MCP capabilities declared in the initialize response. Tools plus the
|
|
50
|
+
* prompts that Cursor surfaces as slash commands; no resources or
|
|
51
|
+
* subscriptions.
|
|
49
52
|
*/
|
|
50
53
|
const CAPABILITIES = {
|
|
51
54
|
tools: { listChanged: false },
|
|
55
|
+
prompts: { listChanged: false },
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
// ---------------------------------------------------------------------------
|
|
@@ -77,6 +81,25 @@ export function compileTools(): Map<string, CompiledTool> {
|
|
|
77
81
|
return map;
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Prompt index - the on-demand actions Cursor surfaces as slash commands
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The prompts the MCP server exposes: the shared actions minus the
|
|
90
|
+
* opencode-only ones. Prompts render the same instruction bodies as the
|
|
91
|
+
* command files (src/commands.ts) without the $ARGUMENTS section - the
|
|
92
|
+
* optional focus argument stands in for it.
|
|
93
|
+
*/
|
|
94
|
+
export function compilePrompts(): Map<string, ActionDef> {
|
|
95
|
+
const map = new Map<string, ActionDef>();
|
|
96
|
+
for (const def of actionDefs(mcpToolName)) {
|
|
97
|
+
if (def.opencodeOnly) continue;
|
|
98
|
+
map.set(def.name, def);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
// ---------------------------------------------------------------------------
|
|
81
104
|
// MCP server
|
|
82
105
|
// ---------------------------------------------------------------------------
|
|
@@ -152,6 +175,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
152
175
|
},
|
|
153
176
|
};
|
|
154
177
|
const tools = compileTools();
|
|
178
|
+
const prompts = compilePrompts();
|
|
155
179
|
|
|
156
180
|
// Check whether `thatch setup` was run for the current host. If not, or if
|
|
157
181
|
// markers are broken, surface a warning on the first tools/call response so
|
|
@@ -248,7 +272,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
248
272
|
|
|
249
273
|
// Notifications (no id) are fire-and-forget - no response expected.
|
|
250
274
|
const isNotification = req.id === null || req.id === undefined;
|
|
251
|
-
const res = await dispatch(req, tools, ctx);
|
|
275
|
+
const res = await dispatch(req, tools, prompts, ctx);
|
|
252
276
|
if (res === null || isNotification) continue;
|
|
253
277
|
|
|
254
278
|
// Surface the setup warning on the first tools/call response so the
|
|
@@ -290,6 +314,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
290
314
|
async function dispatch(
|
|
291
315
|
req: JsonRpcRequest,
|
|
292
316
|
tools: Map<string, CompiledTool>,
|
|
317
|
+
prompts: Map<string, ActionDef>,
|
|
293
318
|
ctx: CoreContext,
|
|
294
319
|
): Promise<JsonRpcResponse | null> {
|
|
295
320
|
const { id, method } = req;
|
|
@@ -355,6 +380,40 @@ async function dispatch(
|
|
|
355
380
|
}
|
|
356
381
|
}
|
|
357
382
|
|
|
383
|
+
case "prompts/list":
|
|
384
|
+
return ok(id, {
|
|
385
|
+
prompts: [...prompts.values()].map((p) => ({
|
|
386
|
+
name: p.name,
|
|
387
|
+
description: p.description,
|
|
388
|
+
arguments: [
|
|
389
|
+
{
|
|
390
|
+
name: "focus",
|
|
391
|
+
description: "Optional scope for the action, e.g. which store to work on",
|
|
392
|
+
required: false,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
})),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
case "prompts/get": {
|
|
399
|
+
const prompt = prompts.get(req.params?.name);
|
|
400
|
+
if (!prompt) {
|
|
401
|
+
return {
|
|
402
|
+
jsonrpc: "2.0",
|
|
403
|
+
id,
|
|
404
|
+
error: { code: METHOD_NOT_FOUND, message: `Unknown prompt: ${req.params?.name}` },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const focus = req.params?.arguments?.focus;
|
|
408
|
+
const text = focus
|
|
409
|
+
? `${prompt.body}\n\nFocus: ${focus}`
|
|
410
|
+
: prompt.body;
|
|
411
|
+
return ok(id, {
|
|
412
|
+
description: prompt.description,
|
|
413
|
+
messages: [{ role: "user", content: { type: "text", text } }],
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
358
417
|
case "ping":
|
|
359
418
|
return ok(id, {});
|
|
360
419
|
|
package/src/prompts.ts
CHANGED
|
@@ -620,6 +620,59 @@ export function versionWarningNudge(message: string): string {
|
|
|
620
620
|
return `[thatch] ${message} This is a system nudge, not user input. After informing the user, continue with their request.`;
|
|
621
621
|
}
|
|
622
622
|
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Prompt cores - behavior instruction bodies shared by nudges and /thatch
|
|
625
|
+
// command files (src/commands.ts). A core is the host-agnostic instruction
|
|
626
|
+
// body for one behavior. The nudge envelope adds escalation tiers and the
|
|
627
|
+
// stop-and-wait ending; the command envelope adds frontmatter and the
|
|
628
|
+
// user-message section. The stop-and-wait ending is nudge-only: a /thatch
|
|
629
|
+
// command IS user input, so it must not inherit it.
|
|
630
|
+
//
|
|
631
|
+
// Cores take a ToolNamer because tool spellings differ per host
|
|
632
|
+
// (thatch_find_duplicates on opencode, mcp__thatch__find_duplicates on MCP
|
|
633
|
+
// hosts); the renderer for each host passes its own namer.
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
/** Maps a bare tool name to the host's spelling. */
|
|
637
|
+
export type ToolNamer = (name: string) => string;
|
|
638
|
+
|
|
639
|
+
export const opencodeToolName: ToolNamer = (name) => `thatch_${name}`;
|
|
640
|
+
export const mcpToolName: ToolNamer = (name) => `mcp__thatch__${name}`;
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The extraction instruction body. sessionIdArg is either ` with session_id
|
|
644
|
+
* "X"` (the nudge path: the sub-agent's session differs from the parent's)
|
|
645
|
+
* or an omit note (the invoking session's ID is the default).
|
|
646
|
+
*/
|
|
647
|
+
export function extractionCore(fetchTool: string, drainTool: string, sessionIdArg: string): string {
|
|
648
|
+
return `Call ${fetchTool}${sessionIdArg} to retrieve the queued tool interactions, then run the thatch-fact-extractor skill to extract durable facts. Call ${drainTool}${sessionIdArg} when finished, even if nothing was worth saving.`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The memory consolidation (defrag) instruction body. Shared by the
|
|
653
|
+
* /thatch/defrag command file and the MCP prompt of the same name.
|
|
654
|
+
*/
|
|
655
|
+
export function defragCore(tool: ToolNamer): string {
|
|
656
|
+
return `Run ${tool("find_duplicates")} to surface duplicate-candidate memory pairs and clusters. For each pair or cluster, read every memory with ${tool("memory_show")}, classify the relationship, and consolidate per the thatch-dedup-classifier skill: merge duplicates (${tool("memory_remember")} with overwrite: true, then ${tool("memory_forget")} the rest), update supplements, keep contradictions, and call ${tool("dedup_mark_checked")} on every pair you are not deleting so it stops being re-reported. Call ${tool("find_duplicates")} again at the end to verify the store is clean, and summarize what you merged, kept, and deleted.`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The memory hygiene instruction body. Shared by the /thatch/hygiene command
|
|
661
|
+
* file and the MCP prompt of the same name. Drives the thatch CLI (not
|
|
662
|
+
* memory tools), so it needs no tool namer.
|
|
663
|
+
*/
|
|
664
|
+
export function hygieneCore(): string {
|
|
665
|
+
return `Run the shell command "thatch hygiene" to print the hygiene report for the current repo store. It lists duplicate-candidate pairs pending review, memories gone stale, and memories scoped to deleted branches. For each finding, act: consolidate duplicates with the thatch-dedup-classifier workflow, correct or overwrite stale memories that are simply out of date, and consolidate branch-scoped memories into one archived record when their branch is gone. Ask the user before forgetting anything that may still matter. Re-run "thatch hygiene" at the end to confirm the store is healthy, and summarize what you changed.`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The session reflection instruction body. Shared by the /thatch/reflect
|
|
670
|
+
* command file and the MCP prompt of the same name.
|
|
671
|
+
*/
|
|
672
|
+
export function reflectCore(tool: ToolNamer): string {
|
|
673
|
+
return `Run the thatch-session-reflection skill to persist what this session learned: follow the skill's instructions and record each distinct learning with ${tool("memory_remember")}. When the skill's checklist is worked through, report a one-line summary of what you saved.`;
|
|
674
|
+
}
|
|
675
|
+
|
|
623
676
|
/**
|
|
624
677
|
* Extraction nudge with escalation. Both the opencode plugin (src/index.ts)
|
|
625
678
|
* and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
|
|
@@ -682,7 +735,8 @@ function buildExtractionNudge(
|
|
|
682
735
|
sessionID: string,
|
|
683
736
|
): string {
|
|
684
737
|
const acknowledge = `After dispatching, call ${drainTool} to acknowledge.`;
|
|
685
|
-
const
|
|
738
|
+
const sessionIdArg = ` with session_id "${sessionID}"`;
|
|
739
|
+
const subAgentPrompt = extractionCore(fetchTool, drainTool, sessionIdArg);
|
|
686
740
|
|
|
687
741
|
if (missedCount >= 3) {
|
|
688
742
|
return `[thatch] YOU ARE IGNORING EXTRACTION INSTRUCTIONS. ` +
|
package/src/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { claudeInstructions, cursorInstructions } from "./prompts";
|
|
4
4
|
import { installSkills, type InstallReport } from "./skills";
|
|
5
|
+
import { installClaudeCommands } from "./commands";
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Path resolution
|
|
@@ -320,6 +321,8 @@ export interface SetupResult {
|
|
|
320
321
|
settings: string;
|
|
321
322
|
/** Skill install report for the directory this run wrote to. */
|
|
322
323
|
skills: InstallReport;
|
|
324
|
+
/** Command files (the /thatch/* actions) written by this run, if any. */
|
|
325
|
+
commands: string[];
|
|
323
326
|
/** Thatch skills in the opposite scope's dir, if any; never written by this run. */
|
|
324
327
|
otherScopeSkills: OtherScopeSkills | null;
|
|
325
328
|
global: boolean;
|
|
@@ -360,6 +363,11 @@ export function setupClaudeCode(
|
|
|
360
363
|
writeHooks(paths.settingsPath, thatchBin);
|
|
361
364
|
const skills = installSkills(paths.skillsDir);
|
|
362
365
|
|
|
366
|
+
// Command files (the /thatch/* actions Claude Code reads as slash
|
|
367
|
+
// commands) live beside skills under the .claude dir, same scope rules.
|
|
368
|
+
const claudeDir = global ? claudeConfigDir(home) : join(projectDir, ".claude");
|
|
369
|
+
const commands = installClaudeCommands(claudeDir);
|
|
370
|
+
|
|
363
371
|
// The scope this run did NOT write to. Local runs leave user-scope copies
|
|
364
372
|
// behind (pre-existing global installs); global runs leave project-local
|
|
365
373
|
// copies in the repo. Either way the user should hear about them.
|
|
@@ -372,6 +380,7 @@ export function setupClaudeCode(
|
|
|
372
380
|
claudeMd: paths.claudeMdPath,
|
|
373
381
|
settings: paths.settingsPath,
|
|
374
382
|
skills,
|
|
383
|
+
commands,
|
|
375
384
|
otherScopeSkills: otherScopeInfo(otherDir),
|
|
376
385
|
global,
|
|
377
386
|
mcpAddCommand: global
|
package/src/tool-defs.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } fr
|
|
|
20
20
|
import { predictionVerb, chatInboxFrame } from "./prompts";
|
|
21
21
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
|
|
22
22
|
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
23
|
-
import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, splitChatRoster, type ChatHostKind } from "./chat";
|
|
23
|
+
import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
24
24
|
import { detectWorktreeKind } from "./git";
|
|
25
25
|
|
|
26
26
|
// Near-duplicate thresholds for matcher/prediction/behavior dedup at
|
|
@@ -452,18 +452,29 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
452
452
|
"Retrieve the queued tool interactions for extraction. Call this with " +
|
|
453
453
|
"the session_id from the extraction nudge to get the JSON payload " +
|
|
454
454
|
"(interactions, projectStore, globalStore). Then run the " +
|
|
455
|
-
"thatch-fact-extractor skill on the returned payload."
|
|
455
|
+
"thatch-fact-extractor skill on the returned payload. Omit session_id " +
|
|
456
|
+
"when acting on the session you are running in.",
|
|
456
457
|
args: {
|
|
457
|
-
session_id: z.string().describe(
|
|
458
|
-
"The session
|
|
459
|
-
"
|
|
458
|
+
session_id: z.string().optional().describe(
|
|
459
|
+
"The session whose tool interactions are queued for extraction. " +
|
|
460
|
+
"Omit when acting on the session you are running in - the invoking " +
|
|
461
|
+
"session's ID is used. Pass the parent session's ID when running " +
|
|
462
|
+
"inside a sub-agent dispatched to process another session's queue.",
|
|
460
463
|
),
|
|
461
464
|
},
|
|
462
|
-
async execute(args, ctx) {
|
|
465
|
+
async execute(args, ctx, host) {
|
|
463
466
|
if (!ctx.extractionPayloadProvider) {
|
|
464
467
|
return "Extraction payload retrieval is not available in this host.";
|
|
465
468
|
}
|
|
466
|
-
|
|
469
|
+
// The zod shape is shared across hosts, so optionality cannot be
|
|
470
|
+
// per-host at the schema level: MCP hosts have no session context to
|
|
471
|
+
// fall back on, so an omitted session_id only resolves on the
|
|
472
|
+
// opencode path, where the wrapper supplies the invoking session.
|
|
473
|
+
const sessionID = (args.session_id as string | undefined) ?? host?.sessionID;
|
|
474
|
+
if (!sessionID) {
|
|
475
|
+
return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
|
|
476
|
+
}
|
|
477
|
+
const payload = ctx.extractionPayloadProvider(sessionID);
|
|
467
478
|
if (!payload) {
|
|
468
479
|
return "No queued tool interactions found for this session.";
|
|
469
480
|
}
|
|
@@ -1722,7 +1733,7 @@ const chatListDef: ToolDef = {
|
|
|
1722
1733
|
return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
|
|
1723
1734
|
}
|
|
1724
1735
|
const unread = ctx.db.unreadChatCount(identity.sessionID);
|
|
1725
|
-
const { active, stale } = splitChatRoster(sessions);
|
|
1736
|
+
const { active, stale } = splitChatRoster(sortChatRoster(sessions));
|
|
1726
1737
|
const line = (s: (typeof sessions)[number]) => {
|
|
1727
1738
|
const self = s.session_id === identity.sessionID;
|
|
1728
1739
|
const liveness = chatLiveness(s);
|